made android client
This commit is contained in:
18
.agents/skills/material-3/.claude-plugin/plugin.json
Normal file
18
.agents/skills/material-3/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "material-3",
|
||||||
|
"version": "1.1.1",
|
||||||
|
"description": "Material Design 3 (Material You) implementation skill for Jetpack Compose, Flutter, and web.",
|
||||||
|
"author": {
|
||||||
|
"name": "Ivan Morgillo"
|
||||||
|
},
|
||||||
|
"repository": "https://github.com/hamen/material-3-skill",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"material-design",
|
||||||
|
"material3",
|
||||||
|
"jetpack-compose",
|
||||||
|
"flutter",
|
||||||
|
"android",
|
||||||
|
"ui"
|
||||||
|
]
|
||||||
|
}
|
||||||
667
.agents/skills/material-3/SKILL.md
Normal file
667
.agents/skills/material-3/SKILL.md
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
---
|
||||||
|
name: material-3
|
||||||
|
description: >
|
||||||
|
Implement Google's Material Design 3 (Material You) UI system. Primary: Jetpack Compose
|
||||||
|
Material3 (MaterialTheme, components, adaptive layout). Also Flutter and limited web
|
||||||
|
(@material/web, maintenance mode). Covers tokens, 30+ components, layout, theming,
|
||||||
|
M3 Expressive (platform matrix), and accessibility. Use when: "material design", "MD3",
|
||||||
|
"material you", "Jetpack Compose", "MaterialTheme", "material component", "md3 button".
|
||||||
|
user-invokable: true
|
||||||
|
argument-hint: "[component|theme|layout|scaffold|audit] [description or URL]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Material Design 3
|
||||||
|
|
||||||
|
This skill guides implementation of Google's Material Design 3 (MD3) — a personal, adaptive, expressive design system. MD3 uses dynamic color, tonal surfaces, rounded shapes, and spring-based motion to create UIs that feel alive and personal.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
MD3 is built on three principles:
|
||||||
|
- **Personal**: Dynamic color adapts UI to the user's wallpaper or content. Theming is individual, not one-size-fits-all.
|
||||||
|
- **Adaptive**: Layouts transform across 5 window size classes. Components resize, reposition, and change form factor responsively.
|
||||||
|
- **Expressive**: Shape morphing, spring physics, and emphasized typography create moments of delight without sacrificing usability.
|
||||||
|
|
||||||
|
## Current Updates: Google I/O 2026
|
||||||
|
|
||||||
|
Material's [Google I/O 2026 update](https://m3.material.io/blog/whats-new-at-io26) reinforces a **Compose-first** Android path and expands expressive/adaptive guidance:
|
||||||
|
|
||||||
|
- **Material Android is Compose-first**: For new Android work, prefer Jetpack Compose Material3 for the latest components, expressive APIs, adaptive scaffolds, and Styles API integration. Android Views may remain necessary in existing apps, but they should not be treated as the default path for new Material 3 implementations.
|
||||||
|
- **Expressive layout system**: Use an expressive layout scaffold to adapt screens across mobile, desktop, foldables, watches, XR, and other spatial form factors. Start from adaptive scaffolds/window size classes instead of fixed phone-first layouts.
|
||||||
|
- **8dp spacing system**: Apply spacing tokens for margins, padding, and gaps so layouts and components can adapt programmatically to device type and density.
|
||||||
|
- **New/updated expressive components**: Lists, menus, search, and search app bars have refreshed expressive guidance, with Jetpack Compose as the primary implementation target.
|
||||||
|
- **Watches and XR**: Watches emphasize physics-based motion, arc text, and edge-hugging containers. XR emphasizes spatial panels and depth-based elevation.
|
||||||
|
|
||||||
|
**Key differences from MD2:**
|
||||||
|
- Tonal surfaces replace elevation shadows as the primary depth cue
|
||||||
|
- Dynamic color generates full schemes from a single seed color
|
||||||
|
- Fully rounded corners by default (not slightly rounded)
|
||||||
|
- Spring-based motion physics replace fixed easing curves for components
|
||||||
|
- 3 levels of user-controlled contrast (standard/medium/high)
|
||||||
|
|
||||||
|
**Relationship with frontend-design skill:**
|
||||||
|
When both skills are active, MD3 provides the design system (tokens, components, layout rules) and frontend-design provides creative direction within those constraints. MD3 rules take precedence for component structure and token usage. Note: Roboto/Roboto Flex IS the correct default typeface in MD3 — the frontend-design guidance to avoid Roboto does not apply when implementing MD3.
|
||||||
|
|
||||||
|
## Decision Tree
|
||||||
|
|
||||||
|
**What are you building?**
|
||||||
|
```
|
||||||
|
Full app scaffold → See "Common Patterns: App Shell" + references/layout-and-responsive.md
|
||||||
|
Single component → See "Component Quick Reference" table → references/component-catalog.md
|
||||||
|
Custom theme → See references/theming-and-dynamic-color.md
|
||||||
|
Form / input layout → See references/component-catalog.md § Input Components
|
||||||
|
Navigation structure → See references/navigation-patterns.md
|
||||||
|
Data display → See references/component-catalog.md § Data Display
|
||||||
|
```
|
||||||
|
|
||||||
|
**What platform?**
|
||||||
|
```
|
||||||
|
Jetpack Compose → Primary: androidx.compose.material3, MaterialTheme, references/*
|
||||||
|
Flutter → useMaterial3: true in ThemeData, ColorScheme.fromSeed()
|
||||||
|
Web (vanilla JS) → @material/web (limited; maintenance mode) + CSS custom properties
|
||||||
|
Web (React/Vue/Svelte) → CSS custom properties + wrapper components (no official React lib)
|
||||||
|
Web (CSS-only) → MD3 token values as CSS custom properties (no <md-*> elements)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Token System
|
||||||
|
|
||||||
|
All MD3 tokens use the `md.sys` namespace. **Jetpack Compose** maps roles to `MaterialTheme.colorScheme`, `MaterialTheme.typography`, and `MaterialTheme.shapes` (same semantic roles as the spec). **On the web**, these map to CSS custom properties (`--md-sys-*`):
|
||||||
|
|
||||||
|
### Color Tokens (`--md-sys-color-*`)
|
||||||
|
| Token | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `primary` | High-emphasis fills, text, icons against surface |
|
||||||
|
| `on-primary` | Text/icons on primary |
|
||||||
|
| `primary-container` | Standout fill for key components (FAB, etc.) |
|
||||||
|
| `on-primary-container` | Text/icons on primary-container |
|
||||||
|
| `secondary` / `on-secondary` | Less prominent accents |
|
||||||
|
| `secondary-container` / `on-secondary-container` | Recessive components (tonal buttons) |
|
||||||
|
| `tertiary` / `on-tertiary` | Contrasting accents |
|
||||||
|
| `tertiary-container` / `on-tertiary-container` | Complementary containers |
|
||||||
|
| `error` / `on-error` | Error states (static — doesn't change with dynamic color) |
|
||||||
|
| `error-container` / `on-error-container` | Error container fills |
|
||||||
|
| `surface` | Default background |
|
||||||
|
| `on-surface` | Text/icons on any surface |
|
||||||
|
| `on-surface-variant` | Lower-emphasis text/icons on surface |
|
||||||
|
| `surface-container-lowest` | Lowest-emphasis container |
|
||||||
|
| `surface-container-low` | Low-emphasis container |
|
||||||
|
| `surface-container` | Default container (nav areas) |
|
||||||
|
| `surface-container-high` | High-emphasis container |
|
||||||
|
| `surface-container-highest` | Highest-emphasis container |
|
||||||
|
| `surface-dim` / `surface-bright` | Maintain relative brightness across light/dark |
|
||||||
|
| `inverse-surface` / `inverse-on-surface` / `inverse-primary` | Contrasting elements (snackbars) |
|
||||||
|
| `outline` | Important boundaries (text field borders) |
|
||||||
|
| `outline-variant` | Decorative elements (dividers) |
|
||||||
|
|
||||||
|
Full details: `references/color-system.md`
|
||||||
|
|
||||||
|
### Typography Tokens (`--md-sys-typescale-*`)
|
||||||
|
| Scale | Sizes | Use |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| Display | L / M / S | Hero text, large numbers |
|
||||||
|
| Headline | L / M / S | Section headers |
|
||||||
|
| Title | L / M / S | Smaller headers, card titles |
|
||||||
|
| Body | L / M / S | Paragraph text, descriptions |
|
||||||
|
| Label | L / M / S | Buttons, chips, captions |
|
||||||
|
|
||||||
|
Each style has tokens for: `-font`, `-weight`, `-size`, `-line-height`, `-tracking`
|
||||||
|
Plus 15 **emphasized** variants (higher weight) via `--md-sys-typescale-emphasized-*`
|
||||||
|
|
||||||
|
Full details: `references/typography-and-shape.md`
|
||||||
|
|
||||||
|
### Shape Tokens (`--md-sys-shape-corner-*`)
|
||||||
|
| Token | Value | Example components |
|
||||||
|
|-------|-------|-------------------|
|
||||||
|
| `none` | 0dp | — |
|
||||||
|
| `extra-small` | 4dp | Chips, snackbars |
|
||||||
|
| `small` | 8dp | Text fields, menus |
|
||||||
|
| `medium` | 12dp | Cards |
|
||||||
|
| `large` | 16dp | FABs, navigation drawer |
|
||||||
|
| `large-increased` | 20dp | (Expressive) |
|
||||||
|
| `extra-large` | 28dp | Dialogs, bottom sheets |
|
||||||
|
| `extra-large-increased` | 32dp | (Expressive) |
|
||||||
|
| `extra-extra-large` | 48dp | (Expressive) |
|
||||||
|
| `full` | 9999px | Buttons, chips, badges |
|
||||||
|
|
||||||
|
### Elevation Levels
|
||||||
|
| Level | DP | Tonal offset | Use |
|
||||||
|
|-------|-----|-------------|-----|
|
||||||
|
| 0 | 0dp | None | Flat surfaces, most components at rest |
|
||||||
|
| 1 | 1dp | +5% primary | Elevated cards, modal sheets |
|
||||||
|
| 2 | 3dp | +8% primary | Menus, nav bar, scrolled app bar |
|
||||||
|
| 3 | 6dp | +11% primary | FAB, dialogs, search, date/time pickers |
|
||||||
|
| 4 | 8dp | +12% primary | (hover/focus increase only) |
|
||||||
|
| 5 | 12dp | +14% primary | (hover/focus increase only) |
|
||||||
|
|
||||||
|
Elevation in MD3 is communicated through **tonal surface color**, not shadows. Shadows are only used when needed for additional protection against busy backgrounds.
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
MD3 Expressive (May 2025) introduced **spring-based motion physics** for components. The legacy easing/duration system is still used for **transitions** (enter/exit/shared-axis):
|
||||||
|
|
||||||
|
| Easing | Duration | Transition type |
|
||||||
|
|--------|----------|-----------------|
|
||||||
|
| Emphasized | 500ms | Begin and end on screen |
|
||||||
|
| Emphasized decelerate | 400ms | Enter the screen |
|
||||||
|
| Emphasized accelerate | 200ms | Exit the screen |
|
||||||
|
| Standard | 300ms | Begin and end on screen (utility) |
|
||||||
|
| Standard decelerate | 250ms | Enter screen (utility) |
|
||||||
|
| Standard accelerate | 200ms | Exit screen (utility) |
|
||||||
|
|
||||||
|
CSS easing values:
|
||||||
|
- Emphasized: `cubic-bezier(0.2, 0, 0, 1)`
|
||||||
|
- Emphasized decelerate: `cubic-bezier(0.05, 0.7, 0.1, 1)`
|
||||||
|
- Emphasized accelerate: `cubic-bezier(0.3, 0, 0.8, 0.15)`
|
||||||
|
- Standard: `cubic-bezier(0.2, 0, 0, 1)`
|
||||||
|
- Standard decelerate: `cubic-bezier(0, 0, 0, 1)`
|
||||||
|
- Standard accelerate: `cubic-bezier(0.3, 0, 1, 1)`
|
||||||
|
|
||||||
|
## Component Quick Reference
|
||||||
|
|
||||||
|
| Component | Web Element | Key Variants | Category |
|
||||||
|
|-----------|------------|--------------|----------|
|
||||||
|
| Button | `md-filled-button`, `md-outlined-button`, `md-text-button`, `md-elevated-button`, `md-filled-tonal-button` | Filled, Outlined, Text, Elevated, Tonal; 5 sizes (XS–XL); toggle | Actions |
|
||||||
|
| Button group | `md-button-group` | Standard, connected | Actions |
|
||||||
|
| Extended FAB | `md-extended-fab` | Surface, Primary, Secondary, Tertiary | Actions |
|
||||||
|
| FAB | `md-fab` | Small, Medium, Large | Actions |
|
||||||
|
| FAB menu | — | — | Actions |
|
||||||
|
| Icon button | `md-icon-button`, `md-filled-icon-button`, `md-filled-tonal-icon-button`, `md-outlined-icon-button` | Standard, Filled, Filled Tonal, Outlined | Actions |
|
||||||
|
| Segmented button | — | Single-select, Multi-select | Actions |
|
||||||
|
| Split button | — | — | Actions |
|
||||||
|
| Badge | — | Small (dot), Large (count) | Communication |
|
||||||
|
| Loading indicator | — | Linear, Circular | Communication |
|
||||||
|
| Progress indicator | `md-linear-progress`, `md-circular-progress` | Linear, Circular; determinate/indeterminate | Communication |
|
||||||
|
| Snackbar | — | Single-line, Two-line, Action | Communication |
|
||||||
|
| Tooltip | — | Plain, Rich | Communication |
|
||||||
|
| Card | — | Filled, Outlined, Elevated | Containment |
|
||||||
|
| Carousel | — | Multi-browse, Uncontained, Hero | Containment |
|
||||||
|
| Dialog | `md-dialog` | Basic, Full-screen | Containment |
|
||||||
|
| Bottom sheet | — | Standard, Modal | Sheets |
|
||||||
|
| Side sheet | — | Standard, Modal | Sheets |
|
||||||
|
| Divider | `md-divider` | Full-width, Inset | Containment |
|
||||||
|
| Checkbox | `md-checkbox` | — | Input |
|
||||||
|
| Chips | `md-chip-set`, `md-assist-chip`, `md-filter-chip`, `md-input-chip`, `md-suggestion-chip` | Assist, Filter, Input, Suggestion | Input |
|
||||||
|
| Date picker | — | Docked, Modal, Range | Input |
|
||||||
|
| Menu | `md-menu`, `md-menu-item` | — | Input |
|
||||||
|
| Radio button | `md-radio` | — | Input |
|
||||||
|
| Slider | `md-slider` | Continuous, Discrete, Range | Input |
|
||||||
|
| Switch | `md-switch` | With/without icon | Input |
|
||||||
|
| Text field | `md-filled-text-field`, `md-outlined-text-field` | Filled, Outlined | Input |
|
||||||
|
| Time picker | — | Docked, Modal | Input |
|
||||||
|
| App bar (top) | — | Center-aligned, Small, Medium, Large | Navigation |
|
||||||
|
| Navigation bar | `md-navigation-bar` | — | Navigation |
|
||||||
|
| Navigation drawer | `md-navigation-drawer` | Standard, Modal | Navigation |
|
||||||
|
| Navigation rail | — | — | Navigation |
|
||||||
|
| Search | — | Search bar, Search view | Navigation |
|
||||||
|
| Tabs | `md-tabs`, `md-primary-tab`, `md-secondary-tab` | Primary, Secondary | Navigation |
|
||||||
|
| Toolbar | — | — | Navigation |
|
||||||
|
| List | `md-list`, `md-list-item` | One-line, Two-line, Three-line | Data Display |
|
||||||
|
|
||||||
|
**Note:** Components marked with `—` for web element don't have @material/web implementations yet. Use CSS custom properties with standard HTML for these. **Compose** mappings and examples live in `references/component-catalog.md`.
|
||||||
|
|
||||||
|
Full component details with code examples: `references/component-catalog.md`
|
||||||
|
|
||||||
|
## Jetpack Compose (primary)
|
||||||
|
|
||||||
|
Use **`androidx.compose.material3`** with `MaterialTheme` and Material 3 composables (`Scaffold`, `Button`, `NavigationBar`, top app bars, etc.).
|
||||||
|
|
||||||
|
- **Theming**: `MaterialTheme(colorScheme = …, typography = …, shapes = …)`. Prefer `dynamicLightColorScheme` / `dynamicDarkColorScheme` on **Android 12+ (API 31+)** when dynamic color is desired; otherwise `lightColorScheme` / `darkColorScheme` or generated theme code from Material Theme Builder.
|
||||||
|
- **Adaptive UI**: Window size classes, list-detail and supporting-pane layouts, foldables — see `references/layout-and-responsive.md` and `references/navigation-patterns.md`.
|
||||||
|
- **Edge-to-edge & insets**: Lay out content with `WindowInsets` / scaffold padding so bars and IME behave correctly — see `references/layout-and-responsive.md`.
|
||||||
|
- **Experimental APIs**: Some Material 3 APIs require `@OptIn(ExperimentalMaterial3Api::class)` or expressive opt-ins; match your BOM and compiler.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme, // from dynamicLightColorScheme / lightColorScheme / etc.
|
||||||
|
typography = Typography(),
|
||||||
|
shapes = Shapes(),
|
||||||
|
) {
|
||||||
|
// M3 content — prefer references for Scaffold, navigation, text fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web (limited): @material/web
|
||||||
|
|
||||||
|
**Important:** Per [Material Design 3 for Web](https://m3.material.io/develop/web), **Material Web Components are in maintenance mode** and **M3 Expressive is not implemented on Web**. Use `@material/web` for token-backed web UIs when appropriate, but do not treat it as equivalent to Compose for current Expressive features.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @material/web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Components Individually
|
||||||
|
|
||||||
|
Always import only the components you use — importing the entire package bloats the bundle:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Good — individual imports
|
||||||
|
import '@material/web/button/filled-button.js';
|
||||||
|
import '@material/web/button/outlined-button.js';
|
||||||
|
import '@material/web/textfield/outlined-text-field.js';
|
||||||
|
import '@material/web/icon/icon.js';
|
||||||
|
|
||||||
|
// Bad — never do this
|
||||||
|
import '@material/web'; // imports everything
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Flex:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<md-filled-button>Get started</md-filled-button>
|
||||||
|
<md-outlined-text-field label="Email" type="email"></md-outlined-text-field>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import '@material/web/button/filled-button.js';
|
||||||
|
import '@material/web/textfield/outlined-text-field.js';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theming with CSS Custom Properties
|
||||||
|
|
||||||
|
Apply a custom theme by setting CSS custom properties on `:root` or any ancestor:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Color scheme (generate with @material/material-color-utilities) */
|
||||||
|
--md-sys-color-primary: #6750A4;
|
||||||
|
--md-sys-color-on-primary: #FFFFFF;
|
||||||
|
--md-sys-color-primary-container: #EADDFF;
|
||||||
|
--md-sys-color-on-primary-container: #21005D;
|
||||||
|
--md-sys-color-secondary: #625B71;
|
||||||
|
--md-sys-color-on-secondary: #FFFFFF;
|
||||||
|
--md-sys-color-secondary-container: #E8DEF8;
|
||||||
|
--md-sys-color-on-secondary-container: #1D192B;
|
||||||
|
--md-sys-color-surface: #FEF7FF;
|
||||||
|
--md-sys-color-on-surface: #1D1B20;
|
||||||
|
--md-sys-color-surface-container: #F3EDF7;
|
||||||
|
--md-sys-color-outline: #79747E;
|
||||||
|
--md-sys-color-outline-variant: #CAC4D0;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--md-sys-typescale-body-large-font: 'Roboto Flex', sans-serif;
|
||||||
|
--md-sys-typescale-body-large-size: 1rem;
|
||||||
|
--md-sys-typescale-body-large-weight: 400;
|
||||||
|
--md-sys-typescale-body-large-line-height: 1.5rem;
|
||||||
|
|
||||||
|
/* Shape */
|
||||||
|
--md-sys-shape-corner-full: 9999px;
|
||||||
|
--md-sys-shape-corner-medium: 12px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component-Level Overrides
|
||||||
|
|
||||||
|
Override individual component tokens for specific customization:
|
||||||
|
|
||||||
|
```css
|
||||||
|
md-filled-button {
|
||||||
|
--md-filled-button-container-color: var(--md-sys-color-primary);
|
||||||
|
--md-filled-button-label-text-color: var(--md-sys-color-on-primary);
|
||||||
|
--md-filled-button-container-shape: var(--md-sys-shape-corner-full);
|
||||||
|
--md-filled-button-container-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
md-outlined-text-field {
|
||||||
|
--md-outlined-text-field-container-shape: var(--md-sys-shape-corner-small);
|
||||||
|
--md-outlined-text-field-focus-outline-color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark Theme
|
||||||
|
|
||||||
|
Apply dark theme by overriding color tokens on a class or media query:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--md-sys-color-primary: #D0BCFF;
|
||||||
|
--md-sys-color-on-primary: #381E72;
|
||||||
|
--md-sys-color-primary-container: #4F378B;
|
||||||
|
--md-sys-color-on-primary-container: #EADDFF;
|
||||||
|
--md-sys-color-surface: #141218;
|
||||||
|
--md-sys-color-on-surface: #E6E0E9;
|
||||||
|
--md-sys-color-surface-container: #211F26;
|
||||||
|
--md-sys-color-outline: #938F99;
|
||||||
|
--md-sys-color-outline-variant: #49454F;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Full theming guide: `references/theming-and-dynamic-color.md`
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### App Shell
|
||||||
|
|
||||||
|
Standard MD3 app with responsive navigation + top app bar + content area:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-app">
|
||||||
|
<nav class="md3-nav-rail" aria-label="Main navigation">
|
||||||
|
<!-- Navigation rail for medium+ screens -->
|
||||||
|
<md-fab size="small" aria-label="Compose">
|
||||||
|
<md-icon slot="icon">edit</md-icon>
|
||||||
|
</md-fab>
|
||||||
|
<md-navigation-bar>
|
||||||
|
<md-navigation-tab label="Home">
|
||||||
|
<md-icon slot="active-icon">home</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">home</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
<md-navigation-tab label="Search">
|
||||||
|
<md-icon slot="active-icon">search</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">search</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
</md-navigation-bar>
|
||||||
|
</nav>
|
||||||
|
<main class="md3-content">
|
||||||
|
<header class="md3-top-app-bar">
|
||||||
|
<h1 class="md3-top-app-bar__title" style="font: var(--md-sys-typescale-title-large)">
|
||||||
|
Page Title
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<div class="md3-body">
|
||||||
|
<!-- Content here -->
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-app {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-nav-rail {
|
||||||
|
width: 80px;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
border-right: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-top-app-bar {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-body {
|
||||||
|
padding: 24px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: switch to bottom nav on compact */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.md3-app { flex-direction: column; }
|
||||||
|
.md3-nav-rail {
|
||||||
|
order: 1;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
border-right: none;
|
||||||
|
border-top: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card Grid
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-card-grid">
|
||||||
|
<div class="md3-card md3-card--outlined">
|
||||||
|
<img src="image.jpg" alt="Description" class="md3-card__media">
|
||||||
|
<div class="md3-card__content">
|
||||||
|
<h3 style="font: var(--md-sys-typescale-title-medium)">Card Title</h3>
|
||||||
|
<p style="font: var(--md-sys-typescale-body-medium); color: var(--md-sys-color-on-surface-variant)">
|
||||||
|
Supporting text for this card.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="md3-card__actions">
|
||||||
|
<md-text-button>Learn more</md-text-button>
|
||||||
|
<md-filled-tonal-button>Action</md-filled-tonal-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-card--outlined {
|
||||||
|
border: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
border-radius: var(--md-sys-shape-corner-medium, 12px);
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-card__content { padding: 16px; }
|
||||||
|
.md3-card__actions { padding: 8px 16px 16px; display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.md3-card__media { width: 100%; aspect-ratio: 16/9; object-fit: cover; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Layout
|
||||||
|
|
||||||
|
```html
|
||||||
|
<form class="md3-form">
|
||||||
|
<md-outlined-text-field label="Full name" required></md-outlined-text-field>
|
||||||
|
<md-outlined-text-field label="Email" type="email" required></md-outlined-text-field>
|
||||||
|
<md-outlined-text-field label="Message" type="textarea" rows="4"></md-outlined-text-field>
|
||||||
|
<div class="md3-form__actions">
|
||||||
|
<md-text-button type="reset">Cancel</md-text-button>
|
||||||
|
<md-filled-button type="submit">Submit</md-filled-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-form__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
More patterns: `references/navigation-patterns.md`, `references/layout-and-responsive.md`
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
**Never do these when implementing MD3:**
|
||||||
|
|
||||||
|
- **Mix MD2 and MD3 libraries**: Don't use `@material/mdc-*` (MD2) alongside `@material/web` (MD3). They have incompatible APIs and styling.
|
||||||
|
- **Hardcode colors**: Always use `var(--md-sys-color-*)` tokens, never raw hex/rgb values. Hardcoded colors break dynamic theming, dark mode, and contrast adjustment.
|
||||||
|
- **Ignore tonal pairing**: Only combine colors in their intended pairs (e.g., `primary` + `on-primary`, `surface-container` + `on-surface`). Arbitrary pairings break contrast in dynamic color and high contrast modes.
|
||||||
|
- **Use `outline` for dividers**: Use `outline-variant` for dividers. `outline` is for important boundaries like text field borders.
|
||||||
|
- **Import all of @material/web**: Always import individual component modules. Barrel imports include every component and destroy bundle size.
|
||||||
|
- **Use `border-radius` directly**: Use shape tokens (`var(--md-sys-shape-corner-medium)`) so shapes stay consistent with theming.
|
||||||
|
- **Use shadows for elevation by default**: MD3 communicates elevation through tonal surface color, not shadows. Only add shadows when elements need extra separation from busy backgrounds.
|
||||||
|
- **Apply frontend-design "avoid Roboto" rule**: On **Android**, **Roboto** is the default Material typeface; **web** often uses Roboto or Roboto Flex with MD3 tokens. Replace only when intentionally customizing the type scale.
|
||||||
|
- **Assume SSR compatibility**: `@material/web` uses Web Components (custom elements) which require JavaScript to render. They won't produce meaningful HTML in SSR without additional hydration strategies.
|
||||||
|
- **Ignore foldables and large screens**: MD3 is designed for all screen sizes. Don't ship phone-only layouts — use canonical layouts, multi-pane at 600dp+, and test on foldable/tablet emulators. Place no interactive content across the fold/hinge.
|
||||||
|
- **Stretch content to fill wide screens**: On Large (1200dp+) and Extra-large (1600dp+) windows, constrain content to a max width (840–1040dp). Endless-width text lines are unreadable.
|
||||||
|
|
||||||
|
## Platform Notes
|
||||||
|
|
||||||
|
### Flutter
|
||||||
|
```dart
|
||||||
|
MaterialApp(
|
||||||
|
theme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jetpack Compose
|
||||||
|
See **[Jetpack Compose (primary)](#jetpack-compose-primary)** above. Use `LocalContext.current` with `dynamicLightColorScheme` / `dynamicDarkColorScheme` only when `Build.VERSION.SDK_INT >= Build.VERSION_CODES.S` and dynamic color is enabled; otherwise supply static light/dark schemes.
|
||||||
|
|
||||||
|
### Component Name Mapping
|
||||||
|
| Concept | Web | Flutter | Compose |
|
||||||
|
|---------|-----|---------|---------|
|
||||||
|
| Filled button | `md-filled-button` | `FilledButton` | `Button` |
|
||||||
|
| Outlined text field | `md-outlined-text-field` | `OutlinedTextField` | `OutlinedTextField` |
|
||||||
|
| FAB | `md-fab` | `FloatingActionButton` | `FloatingActionButton` |
|
||||||
|
| Navigation bar | `md-navigation-bar` | `NavigationBar` | `NavigationBar` |
|
||||||
|
| Switch | `md-switch` | `Switch` | `Switch` |
|
||||||
|
|
||||||
|
## M3 Expressive (May 2025)
|
||||||
|
|
||||||
|
The Expressive update adds visual richness while maintaining usability. **Availability differs by platform** — do not assume one stack implements everything.
|
||||||
|
|
||||||
|
| Capability | Jetpack Compose | Flutter | Web (`@material/web`) |
|
||||||
|
|------------|-----------------|---------|------------------------|
|
||||||
|
| Expressive layout scaffold / adaptive layout | Compose-first via Material3 adaptive APIs and window size classes | Use Flutter adaptive/layout primitives | CSS/container queries/manual layout; no Material Web parity |
|
||||||
|
| 8dp spacing system | Use design tokens / `Dp` spacing constants; keep margins, padding, and gaps adaptive | Use theme spacing constants | CSS custom properties / design tokens |
|
||||||
|
| Expressive lists, menus, search, search app bar | Primary target per current Material guidance; check BOM and opt-ins | Check current Flutter Material docs | Spec-aligned custom implementation; `@material/web` is maintenance-only |
|
||||||
|
| Spring / motion physics | Supported in Material 3 (see `MotionScheme`, expressive APIs per BOM) | Varies by Flutter Material version | **Not** in Material Web; use easing/duration or custom motion |
|
||||||
|
| Emphasized typography | Via theme / type scale | Via theme | Token/CSS only; no full Expressive component set |
|
||||||
|
| Shape morphing | Compose-first in Google’s expressive rollout | Check current Flutter docs | **Not** in `@material/web` |
|
||||||
|
| New button sizes (XS–XL), toggle | Follow Compose Material3 components | Follow Flutter MD3 | Height/CSS approximations only |
|
||||||
|
| Extra corner tokens (e.g. large-increased) | `MaterialTheme.shapes` / tokens | Theme shapes | CSS `--md-sys-shape-*` |
|
||||||
|
| 3 contrast levels | Scheme builders / system | Plugins / manual | `SchemeContent` contrast parameter in JS utilities |
|
||||||
|
| Watches / XR form factors | Use Compose/Wear/XR-specific guidance where available | Platform-specific | Web/spatial UI custom implementation |
|
||||||
|
|
||||||
|
**Web:** [Material Web is maintenance-only; M3 Expressive is not on Web](https://m3.material.io/develop/web). Use CSS easing/duration tokens as fallback for motion, not spring parity.
|
||||||
|
|
||||||
|
**Legacy easing/duration** remains valid for **transitions** (enter/exit/shared-axis) where the spec still references them; see the Motion table below.
|
||||||
|
|
||||||
|
## MD3 Compliance Audit
|
||||||
|
|
||||||
|
When invoked with `audit` as the argument (e.g., `/material-3 audit`), or when asked to audit/review MD3 compliance, analyze the target app or page and produce a compliance report.
|
||||||
|
|
||||||
|
### Audit Procedure
|
||||||
|
|
||||||
|
1. **Identify the target**: The user provides a URL (use browser tools to inspect), file paths (read source), or a running app.
|
||||||
|
2. **Inspect the following categories** and score each 0–10:
|
||||||
|
|
||||||
|
| Category | What to check |
|
||||||
|
|----------|--------------|
|
||||||
|
| **Color tokens** | **Web:** `--md-sys-color-*` / generated CSS. **Compose:** `MaterialTheme.colorScheme` roles (no arbitrary `Color(...)` for surfaces without reason). Proper tonal pairing (`onX` on `X`). Dark theme. **Flutter:** `ColorScheme` roles. |
|
||||||
|
| **Typography** | MD3 type scale: **Compose** `MaterialTheme.typography`; **web** typescale tokens; correct roles (Display, Headline, Title, Body, Label). |
|
||||||
|
| **Shape** | **Compose** `MaterialTheme.shapes` / component `Shape`; **web** `var(--md-sys-shape-*)`. Buttons: full; cards: medium; avoid magic numbers. |
|
||||||
|
| **Elevation** | Tonal elevation (`Surface` tonal/shadow as appropriate). **Web:** hover/focus where relevant. |
|
||||||
|
| **Components** | **Compose:** Material3 composables (`Button`, `Scaffold`, etc.). **Web:** `@material/web` or spec-aligned HTML/CSS. Correct variants. |
|
||||||
|
| **Layout** | Canonical layouts; **Compose** window size class / adaptive APIs; readable max width on large widths; foldable hinge avoidance. |
|
||||||
|
| **Navigation** | Bar / rail / drawer / drawers+**Compose** `NavHost` patterns per size class; predictive back where applicable. |
|
||||||
|
| **Motion** | **Compose** `MotionScheme` / expressive APIs when used; transitions may still use easing/duration. **Web:** CSS motion tokens fallback. |
|
||||||
|
| **Accessibility** | MD3 roles help, but **verify contrast**: UI components often need **3:1** for large text/borders and **4.5:1** for normal text (WCAG 2.x). TalkBack/semantics (Compose), focus order, touch targets (~48dp). **Web:** ARIA, keyboard. |
|
||||||
|
| **Theming** | **Compose:** `MaterialTheme` + light/dark/dynamic as designed. **Web:** CSS custom properties on `:root` or subtree. **Flutter:** `ThemeData` + `ColorScheme`. |
|
||||||
|
|
||||||
|
3. **Generate the report**:
|
||||||
|
|
||||||
|
```
|
||||||
|
# MD3 Compliance Audit Report
|
||||||
|
|
||||||
|
Target: [URL or file path]
|
||||||
|
Date: [date]
|
||||||
|
Overall Score: [X/100]
|
||||||
|
|
||||||
|
## Scores by Category
|
||||||
|
| Category | Score | Status |
|
||||||
|
|----------------|-------|--------|
|
||||||
|
| Color tokens | X/10 | [pass/warn/fail] |
|
||||||
|
| Typography | X/10 | [pass/warn/fail] |
|
||||||
|
| Shape | X/10 | [pass/warn/fail] |
|
||||||
|
| Elevation | X/10 | [pass/warn/fail] |
|
||||||
|
| Components | X/10 | [pass/warn/fail] |
|
||||||
|
| Layout | X/10 | [pass/warn/fail] |
|
||||||
|
| Navigation | X/10 | [pass/warn/fail] |
|
||||||
|
| Motion | X/10 | [pass/warn/fail] |
|
||||||
|
| Accessibility | X/10 | [pass/warn/fail] |
|
||||||
|
| Theming | X/10 | [pass/warn/fail] |
|
||||||
|
|
||||||
|
## Critical Issues
|
||||||
|
[List items scoring 0-3 with specific file:line references and fixes]
|
||||||
|
|
||||||
|
## Warnings
|
||||||
|
[List items scoring 4-6 with recommendations]
|
||||||
|
|
||||||
|
## Passing
|
||||||
|
[List items scoring 7-10 with notes on what's done well]
|
||||||
|
|
||||||
|
## Recommended Fixes (Priority Order)
|
||||||
|
1. [Most impactful fix first]
|
||||||
|
2. ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit Methods
|
||||||
|
|
||||||
|
**For a live URL** (browser or devtools):
|
||||||
|
- Inspect computed styles and CSS variables (`--md-sys-*`)
|
||||||
|
- Resize viewport or use responsive mode for breakpoints
|
||||||
|
- Capture screenshots at key widths if helpful
|
||||||
|
|
||||||
|
**For source code** (file paths provided):
|
||||||
|
- **Compose/Kotlin:** `.kt` files — `MaterialTheme`, composables, `Color(0x…)` abuse, hard-coded `Dp`, missing `Modifier.semantics` where needed
|
||||||
|
- **Flutter:** `.dart` — `ThemeData`, `ColorScheme`
|
||||||
|
- **Web:** HTML/JSX/Vue/Svelte; CSS/SCSS for tokens
|
||||||
|
- Check **web** imports for `@material/web` vs `@material/mdc-*` (MD2)
|
||||||
|
|
||||||
|
**Quick checks** (adapt paths to your stack):
|
||||||
|
```
|
||||||
|
# Web: hardcoded colors
|
||||||
|
grep -rn '#[0-9a-fA-F]\{3,8\}' --include='*.css' --include='*.scss'
|
||||||
|
|
||||||
|
# Compose: raw Color(...) audits (sample — tune for your codebase)
|
||||||
|
grep -rn 'Color(0x' --include='*.kt'
|
||||||
|
|
||||||
|
# MD2 on web
|
||||||
|
grep -rn '@material/mdc-' --include='*.js' --include='*.ts'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Browser automation** (if your environment exposes MCP browser tools): navigate, snapshot DOM/CSS variables, resize for breakpoints — optional, not required.
|
||||||
|
|
||||||
|
### Scoring Guide
|
||||||
|
|
||||||
|
- **9-10**: Fully MD3 compliant, uses correct tokens and patterns
|
||||||
|
- **7-8**: Mostly compliant, minor issues (e.g., a few hardcoded values)
|
||||||
|
- **4-6**: Partially compliant, some MD3 patterns but significant gaps
|
||||||
|
- **1-3**: Major violations, mostly non-MD3 or MD2 patterns
|
||||||
|
- **0**: Not applicable or completely absent
|
||||||
|
|
||||||
|
Status thresholds: **pass** (7+), **warn** (4-6), **fail** (0-3)
|
||||||
|
|
||||||
|
## Reference Documents
|
||||||
|
|
||||||
|
- `references/color-system.md` — Color roles, tonal palettes, dynamic color, Compose + CSS mapping
|
||||||
|
- `references/typography-and-shape.md` — Type scale, shape corners, elevation, motion, Expressive notes
|
||||||
|
- `references/component-catalog.md` — Components: Compose + `@material/web` where applicable
|
||||||
|
- `references/navigation-patterns.md` — Navigation selection, Compose-first adaptive patterns
|
||||||
|
- `references/layout-and-responsive.md` — Breakpoints, canonical layouts, insets, foldables
|
||||||
|
- `references/theming-and-dynamic-color.md` — Theming: Compose first, then Flutter and web
|
||||||
358
.agents/skills/material-3/references/color-system.md
Normal file
358
.agents/skills/material-3/references/color-system.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
# MD3 Color System
|
||||||
|
|
||||||
|
Complete reference for Material Design 3's color system: roles, tonal palettes, dynamic color, and scheme generation.
|
||||||
|
|
||||||
|
## Color Roles
|
||||||
|
|
||||||
|
MD3 defines 29+ color roles organized into groups. **Jetpack Compose:** map to `MaterialTheme.colorScheme` (e.g. `primary`, `onPrimary`). **Web:** each role exists as a CSS custom property `--md-sys-color-{role-name}`.
|
||||||
|
|
||||||
|
### Accent Colors
|
||||||
|
|
||||||
|
Three accent groups (primary, secondary, tertiary) each have 4 roles:
|
||||||
|
|
||||||
|
| Role | CSS Token | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| Primary | `--md-sys-color-primary` | High-emphasis fills, text, icons against surface |
|
||||||
|
| On Primary | `--md-sys-color-on-primary` | Text and icons on primary |
|
||||||
|
| Primary Container | `--md-sys-color-primary-container` | Standout fill for key components (FAB, etc.) |
|
||||||
|
| On Primary Container | `--md-sys-color-on-primary-container` | Text and icons on primary-container |
|
||||||
|
| Secondary | `--md-sys-color-secondary` | Less prominent fills, text, icons |
|
||||||
|
| On Secondary | `--md-sys-color-on-secondary` | Text and icons on secondary |
|
||||||
|
| Secondary Container | `--md-sys-color-secondary-container` | Recessive components (tonal buttons) |
|
||||||
|
| On Secondary Container | `--md-sys-color-on-secondary-container` | Text and icons on secondary-container |
|
||||||
|
| Tertiary | `--md-sys-color-tertiary` | Complementary fills, text, icons |
|
||||||
|
| On Tertiary | `--md-sys-color-on-tertiary` | Text and icons on tertiary |
|
||||||
|
| Tertiary Container | `--md-sys-color-tertiary-container` | Complementary container fill |
|
||||||
|
| On Tertiary Container | `--md-sys-color-on-tertiary-container` | Text and icons on tertiary-container |
|
||||||
|
|
||||||
|
**Usage guidance:**
|
||||||
|
- **Primary**: Most prominent components — FABs, high-emphasis buttons, active states
|
||||||
|
- **Secondary**: Less prominent components — filter chips, tonal buttons, selection states
|
||||||
|
- **Tertiary**: Contrasting accents that balance primary/secondary — input fields, badges
|
||||||
|
|
||||||
|
### Error Colors
|
||||||
|
|
||||||
|
Static colors that don't change with dynamic color schemes:
|
||||||
|
|
||||||
|
| Role | CSS Token | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| Error | `--md-sys-color-error` | Attention-grabbing color for urgent elements |
|
||||||
|
| On Error | `--md-sys-color-on-error` | Text and icons on error |
|
||||||
|
| Error Container | `--md-sys-color-error-container` | Error container fill |
|
||||||
|
| On Error Container | `--md-sys-color-on-error-container` | Text and icons on error-container |
|
||||||
|
|
||||||
|
### Surface Colors
|
||||||
|
|
||||||
|
| Role | CSS Token | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| Surface | `--md-sys-color-surface` | Default background color |
|
||||||
|
| On Surface | `--md-sys-color-on-surface` | Text and icons on any surface |
|
||||||
|
| On Surface Variant | `--md-sys-color-on-surface-variant` | Lower-emphasis text/icons on surface |
|
||||||
|
| Surface Container Lowest | `--md-sys-color-surface-container-lowest` | Lowest-emphasis container |
|
||||||
|
| Surface Container Low | `--md-sys-color-surface-container-low` | Low-emphasis container |
|
||||||
|
| Surface Container | `--md-sys-color-surface-container` | Default container (navigation areas) |
|
||||||
|
| Surface Container High | `--md-sys-color-surface-container-high` | High-emphasis container |
|
||||||
|
| Surface Container Highest | `--md-sys-color-surface-container-highest` | Highest-emphasis container |
|
||||||
|
| Surface Dim | `--md-sys-color-surface-dim` | Dimmest surface in both themes |
|
||||||
|
| Surface Bright | `--md-sys-color-surface-bright` | Brightest surface in both themes |
|
||||||
|
|
||||||
|
**Surface container hierarchy**: Use `surface` for background, `surface-container` for navigation. The 5 container levels create visual hierarchy and nesting depth, especially useful for expanded layouts with multiple panes.
|
||||||
|
|
||||||
|
**Surface dim/bright**: Unlike regular surface (which flips from light to dark), dim and bright maintain their relative brightness in both themes. Use bright for areas that should always be the lightest, dim for areas that should always be the dimmest.
|
||||||
|
|
||||||
|
### Inverse Colors
|
||||||
|
|
||||||
|
For elements that contrast against the surrounding UI (e.g., snackbars):
|
||||||
|
|
||||||
|
| Role | CSS Token | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| Inverse Surface | `--md-sys-color-inverse-surface` | Background for contrasting elements |
|
||||||
|
| Inverse On Surface | `--md-sys-color-inverse-on-surface` | Text on inverse-surface |
|
||||||
|
| Inverse Primary | `--md-sys-color-inverse-primary` | Actionable text on inverse-surface |
|
||||||
|
|
||||||
|
### Outline Colors
|
||||||
|
|
||||||
|
| Role | CSS Token | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| Outline | `--md-sys-color-outline` | Important boundaries (text field borders, 3:1 contrast) |
|
||||||
|
| Outline Variant | `--md-sys-color-outline-variant` | Decorative elements (dividers, card borders) |
|
||||||
|
|
||||||
|
**Important**: Don't use `outline` for dividers — use `outline-variant`. Don't use `outline-variant` for interactive boundaries that need 3:1 contrast — use `outline`.
|
||||||
|
|
||||||
|
### Fixed Accent Colors (Add-on)
|
||||||
|
|
||||||
|
These maintain the same color in both light and dark themes (unlike regular container colors which change tone):
|
||||||
|
|
||||||
|
| Role | CSS Token |
|
||||||
|
|------|-----------|
|
||||||
|
| Primary Fixed | `--md-sys-color-primary-fixed` |
|
||||||
|
| Primary Fixed Dim | `--md-sys-color-primary-fixed-dim` |
|
||||||
|
| On Primary Fixed | `--md-sys-color-on-primary-fixed` |
|
||||||
|
| On Primary Fixed Variant | `--md-sys-color-on-primary-fixed-variant` |
|
||||||
|
| Secondary Fixed | `--md-sys-color-secondary-fixed` |
|
||||||
|
| Secondary Fixed Dim | `--md-sys-color-secondary-fixed-dim` |
|
||||||
|
| On Secondary Fixed | `--md-sys-color-on-secondary-fixed` |
|
||||||
|
| On Secondary Fixed Variant | `--md-sys-color-on-secondary-fixed-variant` |
|
||||||
|
| Tertiary Fixed | `--md-sys-color-tertiary-fixed` |
|
||||||
|
| Tertiary Fixed Dim | `--md-sys-color-tertiary-fixed-dim` |
|
||||||
|
| On Tertiary Fixed | `--md-sys-color-on-tertiary-fixed` |
|
||||||
|
| On Tertiary Fixed Variant | `--md-sys-color-on-tertiary-fixed-variant` |
|
||||||
|
|
||||||
|
**Caution**: Fixed colors don't adapt to theme, so they may cause contrast issues. Use regular accent roles for elements where contrast is critical.
|
||||||
|
|
||||||
|
## Tonal Palette System
|
||||||
|
|
||||||
|
MD3 generates colors from a **seed color** through the tonal palette system:
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. A **seed color** (hex value) is chosen
|
||||||
|
2. The seed generates **5 tonal palettes**: Primary, Secondary, Tertiary, Neutral, Neutral-Variant
|
||||||
|
3. Each palette uses tonal stops along **0–100** (commonly 16 key stops: 0, 10, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100)
|
||||||
|
4. Color roles are mapped to specific tonal values depending on light or dark scheme
|
||||||
|
|
||||||
|
### Tonal Value Mapping (Light Scheme)
|
||||||
|
|
||||||
|
| Role | Tonal Palette | Tone |
|
||||||
|
|------|--------------|------|
|
||||||
|
| Primary | Primary | 40 |
|
||||||
|
| On Primary | Primary | 100 |
|
||||||
|
| Primary Container | Primary | 90 |
|
||||||
|
| On Primary Container | Primary | 10 |
|
||||||
|
| Surface | Neutral | 98 |
|
||||||
|
| On Surface | Neutral | 10 |
|
||||||
|
| Surface Container | Neutral | 94 |
|
||||||
|
| Surface Container Low | Neutral | 96 |
|
||||||
|
| Surface Container Lowest | Neutral | 100 |
|
||||||
|
| Surface Container High | Neutral | 92 |
|
||||||
|
| Surface Container Highest | Neutral | 90 |
|
||||||
|
| Outline | Neutral-Variant | 50 |
|
||||||
|
| Outline Variant | Neutral-Variant | 80 |
|
||||||
|
|
||||||
|
### Tonal Value Mapping (Dark Scheme)
|
||||||
|
|
||||||
|
| Role | Tonal Palette | Tone |
|
||||||
|
|------|--------------|------|
|
||||||
|
| Primary | Primary | 80 |
|
||||||
|
| On Primary | Primary | 20 |
|
||||||
|
| Primary Container | Primary | 30 |
|
||||||
|
| On Primary Container | Primary | 90 |
|
||||||
|
| Surface | Neutral | 6 |
|
||||||
|
| On Surface | Neutral | 90 |
|
||||||
|
| Surface Container | Neutral | 12 |
|
||||||
|
| Surface Container Low | Neutral | 10 |
|
||||||
|
| Surface Container Lowest | Neutral | 4 |
|
||||||
|
| Surface Container High | Neutral | 17 |
|
||||||
|
| Surface Container Highest | Neutral | 22 |
|
||||||
|
| Outline | Neutral-Variant | 60 |
|
||||||
|
| Outline Variant | Neutral-Variant | 30 |
|
||||||
|
|
||||||
|
## Color Pairing Rules
|
||||||
|
|
||||||
|
Colors must only be used in their intended pairs to ensure accessible contrast:
|
||||||
|
|
||||||
|
| Container/Fill | Text/Icon Color |
|
||||||
|
|---------------|----------------|
|
||||||
|
| `primary` | `on-primary` |
|
||||||
|
| `primary-container` | `on-primary-container` |
|
||||||
|
| `secondary` | `on-secondary` |
|
||||||
|
| `secondary-container` | `on-secondary-container` |
|
||||||
|
| `tertiary` | `on-tertiary` |
|
||||||
|
| `tertiary-container` | `on-tertiary-container` |
|
||||||
|
| `error` | `on-error` |
|
||||||
|
| `error-container` | `on-error-container` |
|
||||||
|
| `surface` | `on-surface` or `on-surface-variant` |
|
||||||
|
| `surface-container-*` | `on-surface` or `on-surface-variant` |
|
||||||
|
| `inverse-surface` | `inverse-on-surface` or `inverse-primary` |
|
||||||
|
|
||||||
|
**Never pair colors outside their intended pairs** — this breaks contrast guarantees, especially under dynamic color and high contrast modes.
|
||||||
|
|
||||||
|
## Dynamic Color
|
||||||
|
|
||||||
|
Dynamic color creates personalized color schemes from external sources:
|
||||||
|
|
||||||
|
### User-Generated (Wallpaper)
|
||||||
|
The OS extracts a seed color from the user's wallpaper and generates a scheme. **Android:** `dynamicLightColorScheme` / `dynamicDarkColorScheme` on **Android 12+ (API 31+)**. **Web:** there is **no** browser wallpaper dynamic-color API equivalent; you can derive a seed from **content** (e.g. images) with libraries, but that is app-specific, not system wallpaper theming.
|
||||||
|
|
||||||
|
### Content-Based
|
||||||
|
A seed color is extracted from in-app content (album art, book cover, etc.) to create a contextual scheme.
|
||||||
|
|
||||||
|
### Generating a Scheme with JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import {
|
||||||
|
argbFromHex,
|
||||||
|
themeFromSourceColor,
|
||||||
|
applyTheme,
|
||||||
|
} from '@material/material-color-utilities';
|
||||||
|
|
||||||
|
// Generate a theme from a seed color
|
||||||
|
const theme = themeFromSourceColor(argbFromHex('#6750A4'));
|
||||||
|
|
||||||
|
// Apply to the document
|
||||||
|
applyTheme(theme, { target: document.body, dark: false });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual CSS Generation from Seed
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import {
|
||||||
|
argbFromHex,
|
||||||
|
hexFromArgb,
|
||||||
|
SchemeContent,
|
||||||
|
Hct,
|
||||||
|
} from '@material/material-color-utilities';
|
||||||
|
|
||||||
|
function generateScheme(seedHex, isDark = false) {
|
||||||
|
const hct = Hct.fromInt(argbFromHex(seedHex));
|
||||||
|
const scheme = new SchemeContent(hct, isDark, 0.0); // 0.0 = standard contrast
|
||||||
|
|
||||||
|
return {
|
||||||
|
'--md-sys-color-primary': hexFromArgb(scheme.primary),
|
||||||
|
'--md-sys-color-on-primary': hexFromArgb(scheme.onPrimary),
|
||||||
|
'--md-sys-color-primary-container': hexFromArgb(scheme.primaryContainer),
|
||||||
|
'--md-sys-color-on-primary-container': hexFromArgb(scheme.onPrimaryContainer),
|
||||||
|
'--md-sys-color-secondary': hexFromArgb(scheme.secondary),
|
||||||
|
'--md-sys-color-on-secondary': hexFromArgb(scheme.onSecondary),
|
||||||
|
'--md-sys-color-secondary-container': hexFromArgb(scheme.secondaryContainer),
|
||||||
|
'--md-sys-color-on-secondary-container': hexFromArgb(scheme.onSecondaryContainer),
|
||||||
|
'--md-sys-color-tertiary': hexFromArgb(scheme.tertiary),
|
||||||
|
'--md-sys-color-on-tertiary': hexFromArgb(scheme.onTertiary),
|
||||||
|
'--md-sys-color-tertiary-container': hexFromArgb(scheme.tertiaryContainer),
|
||||||
|
'--md-sys-color-on-tertiary-container': hexFromArgb(scheme.onTertiaryContainer),
|
||||||
|
'--md-sys-color-error': hexFromArgb(scheme.error),
|
||||||
|
'--md-sys-color-on-error': hexFromArgb(scheme.onError),
|
||||||
|
'--md-sys-color-error-container': hexFromArgb(scheme.errorContainer),
|
||||||
|
'--md-sys-color-on-error-container': hexFromArgb(scheme.onErrorContainer),
|
||||||
|
'--md-sys-color-surface': hexFromArgb(scheme.surface),
|
||||||
|
'--md-sys-color-on-surface': hexFromArgb(scheme.onSurface),
|
||||||
|
'--md-sys-color-on-surface-variant': hexFromArgb(scheme.onSurfaceVariant),
|
||||||
|
'--md-sys-color-surface-container': hexFromArgb(scheme.surfaceContainer),
|
||||||
|
'--md-sys-color-surface-container-low': hexFromArgb(scheme.surfaceContainerLow),
|
||||||
|
'--md-sys-color-surface-container-lowest': hexFromArgb(scheme.surfaceContainerLowest),
|
||||||
|
'--md-sys-color-surface-container-high': hexFromArgb(scheme.surfaceContainerHigh),
|
||||||
|
'--md-sys-color-surface-container-highest': hexFromArgb(scheme.surfaceContainerHighest),
|
||||||
|
'--md-sys-color-outline': hexFromArgb(scheme.outline),
|
||||||
|
'--md-sys-color-outline-variant': hexFromArgb(scheme.outlineVariant),
|
||||||
|
'--md-sys-color-inverse-surface': hexFromArgb(scheme.inverseSurface),
|
||||||
|
'--md-sys-color-inverse-on-surface': hexFromArgb(scheme.inverseOnSurface),
|
||||||
|
'--md-sys-color-inverse-primary': hexFromArgb(scheme.inversePrimary),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to document
|
||||||
|
function applyScheme(seedHex, isDark = false) {
|
||||||
|
const tokens = generateScheme(seedHex, isDark);
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [property, value] of Object.entries(tokens)) {
|
||||||
|
root.style.setProperty(property, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Color Harmonization
|
||||||
|
|
||||||
|
When integrating custom brand colors that don't come from the seed, use harmonization to blend them into the tonal system:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { Blend } from '@material/material-color-utilities';
|
||||||
|
|
||||||
|
// Harmonize a custom color with the primary color
|
||||||
|
const harmonized = Blend.harmonize(customColorArgb, primaryColorArgb);
|
||||||
|
```
|
||||||
|
|
||||||
|
This shifts the custom color's hue slightly toward the scheme's primary, making it feel cohesive without losing its identity.
|
||||||
|
|
||||||
|
## User-Controlled Contrast (May 2025)
|
||||||
|
|
||||||
|
MD3 now supports 3 contrast levels:
|
||||||
|
- **Standard** (0.0): Default contrast
|
||||||
|
- **Medium** (0.5): Increased tonal distance between roles
|
||||||
|
- **High** (1.0): Maximum tonal distance for vision accessibility
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Generate high contrast scheme
|
||||||
|
const scheme = new SchemeContent(hct, isDark, 1.0); // 1.0 = high contrast
|
||||||
|
```
|
||||||
|
|
||||||
|
The contrast parameter adjusts the tonal distance between paired roles, increasing legibility without changing the overall color feel.
|
||||||
|
|
||||||
|
## Baseline Color Scheme (Default Values)
|
||||||
|
|
||||||
|
The static baseline scheme for products not using dynamic color:
|
||||||
|
|
||||||
|
### Light Theme
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--md-sys-color-primary: #6750A4;
|
||||||
|
--md-sys-color-on-primary: #FFFFFF;
|
||||||
|
--md-sys-color-primary-container: #EADDFF;
|
||||||
|
--md-sys-color-on-primary-container: #21005D;
|
||||||
|
--md-sys-color-secondary: #625B71;
|
||||||
|
--md-sys-color-on-secondary: #FFFFFF;
|
||||||
|
--md-sys-color-secondary-container: #E8DEF8;
|
||||||
|
--md-sys-color-on-secondary-container: #1D192B;
|
||||||
|
--md-sys-color-tertiary: #7D5260;
|
||||||
|
--md-sys-color-on-tertiary: #FFFFFF;
|
||||||
|
--md-sys-color-tertiary-container: #FFD8E4;
|
||||||
|
--md-sys-color-on-tertiary-container: #31111D;
|
||||||
|
--md-sys-color-error: #B3261E;
|
||||||
|
--md-sys-color-on-error: #FFFFFF;
|
||||||
|
--md-sys-color-error-container: #F9DEDC;
|
||||||
|
--md-sys-color-on-error-container: #410E0B;
|
||||||
|
--md-sys-color-surface: #FEF7FF;
|
||||||
|
--md-sys-color-on-surface: #1D1B20;
|
||||||
|
--md-sys-color-on-surface-variant: #49454F;
|
||||||
|
--md-sys-color-surface-container-lowest: #FFFFFF;
|
||||||
|
--md-sys-color-surface-container-low: #F7F2FA;
|
||||||
|
--md-sys-color-surface-container: #F3EDF7;
|
||||||
|
--md-sys-color-surface-container-high: #ECE6F0;
|
||||||
|
--md-sys-color-surface-container-highest: #E6E0E9;
|
||||||
|
--md-sys-color-surface-dim: #DED8E1;
|
||||||
|
--md-sys-color-surface-bright: #FEF7FF;
|
||||||
|
--md-sys-color-outline: #79747E;
|
||||||
|
--md-sys-color-outline-variant: #CAC4D0;
|
||||||
|
--md-sys-color-inverse-surface: #322F35;
|
||||||
|
--md-sys-color-inverse-on-surface: #F5EFF7;
|
||||||
|
--md-sys-color-inverse-primary: #D0BCFF;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark Theme
|
||||||
|
```css
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--md-sys-color-primary: #D0BCFF;
|
||||||
|
--md-sys-color-on-primary: #381E72;
|
||||||
|
--md-sys-color-primary-container: #4F378B;
|
||||||
|
--md-sys-color-on-primary-container: #EADDFF;
|
||||||
|
--md-sys-color-secondary: #CCC2DC;
|
||||||
|
--md-sys-color-on-secondary: #332D41;
|
||||||
|
--md-sys-color-secondary-container: #4A4458;
|
||||||
|
--md-sys-color-on-secondary-container: #E8DEF8;
|
||||||
|
--md-sys-color-tertiary: #EFB8C8;
|
||||||
|
--md-sys-color-on-tertiary: #492532;
|
||||||
|
--md-sys-color-tertiary-container: #633B48;
|
||||||
|
--md-sys-color-on-tertiary-container: #FFD8E4;
|
||||||
|
--md-sys-color-error: #F2B8B5;
|
||||||
|
--md-sys-color-on-error: #601410;
|
||||||
|
--md-sys-color-error-container: #8C1D18;
|
||||||
|
--md-sys-color-on-error-container: #F9DEDC;
|
||||||
|
--md-sys-color-surface: #141218;
|
||||||
|
--md-sys-color-on-surface: #E6E0E9;
|
||||||
|
--md-sys-color-on-surface-variant: #CAC4D0;
|
||||||
|
--md-sys-color-surface-container-lowest: #0F0D13;
|
||||||
|
--md-sys-color-surface-container-low: #1D1B20;
|
||||||
|
--md-sys-color-surface-container: #211F26;
|
||||||
|
--md-sys-color-surface-container-high: #2B2930;
|
||||||
|
--md-sys-color-surface-container-highest: #36343B;
|
||||||
|
--md-sys-color-surface-dim: #141218;
|
||||||
|
--md-sys-color-surface-bright: #3B383E;
|
||||||
|
--md-sys-color-outline: #938F99;
|
||||||
|
--md-sys-color-outline-variant: #49454F;
|
||||||
|
--md-sys-color-inverse-surface: #E6E0E9;
|
||||||
|
--md-sys-color-inverse-on-surface: #322F35;
|
||||||
|
--md-sys-color-inverse-primary: #6750A4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
836
.agents/skills/material-3/references/component-catalog.md
Normal file
836
.agents/skills/material-3/references/component-catalog.md
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
# MD3 Component Catalog
|
||||||
|
|
||||||
|
Complete reference for Material Design 3 components. **Primary mappings:** Jetpack Compose (`androidx.compose.material3`) where most users ship UIs today; **web** uses `@material/web` element names and imports — [Material Web is maintenance-only](https://m3.material.io/develop/web).
|
||||||
|
|
||||||
|
## Google I/O 2026 Component Updates
|
||||||
|
|
||||||
|
Material's [I/O 2026 update](https://m3.material.io/blog/whats-new-at-io26) highlights refreshed expressive guidance for **lists**, **menus**, **search**, and **search app bars**, with Jetpack Compose as the primary implementation path. When implementing these in Android:
|
||||||
|
|
||||||
|
- Prefer current `androidx.compose.material3` components and verify expressive APIs against the active Material3 BOM.
|
||||||
|
- Expect expressive variants to include richer visual style, motion, and flexible configuration.
|
||||||
|
- Treat web implementations as spec-aligned approximations unless Material Web exposes an equivalent component; Material Web remains maintenance-only.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
MD3 has 5 button types ordered by emphasis: Filled > Filled Tonal > Elevated > Outlined > Text.
|
||||||
|
|
||||||
|
**General attributes** (shared by all button types):
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `disabled` | boolean | Disables the button |
|
||||||
|
| `href` | string | Turns button into a link |
|
||||||
|
| `target` | string | Link target (`_blank`, etc.) |
|
||||||
|
| `trailing-icon` | boolean | Moves icon to trailing position |
|
||||||
|
| `type` | string | Form type: `button`, `submit`, `reset` |
|
||||||
|
|
||||||
|
#### Filled Button
|
||||||
|
**Element**: `md-filled-button` | **Import**: `@material/web/button/filled-button.js`
|
||||||
|
**Use when**: Primary action, highest emphasis.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-filled-button>Get started</md-filled-button>
|
||||||
|
<md-filled-button href="/signup">
|
||||||
|
<md-icon slot="icon">arrow_forward</md-icon>
|
||||||
|
Sign up
|
||||||
|
</md-filled-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize**: `--md-filled-button-container-color`, `--md-filled-button-label-text-color`, `--md-filled-button-container-shape`, `--md-filled-button-container-height`
|
||||||
|
|
||||||
|
#### Filled Tonal Button
|
||||||
|
**Element**: `md-filled-tonal-button` | **Import**: `@material/web/button/filled-tonal-button.js`
|
||||||
|
**Use when**: Medium emphasis, softer than filled. Secondary actions alongside a filled button.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-filled-tonal-button>Save draft</md-filled-tonal-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize**: `--md-filled-tonal-button-container-color`, `--md-filled-tonal-button-label-text-color`
|
||||||
|
|
||||||
|
#### Elevated Button
|
||||||
|
**Element**: `md-elevated-button` | **Import**: `@material/web/button/elevated-button.js`
|
||||||
|
**Use when**: Medium emphasis with shadow. Use on colored backgrounds where tonal button blends in.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-elevated-button>Add to cart</md-elevated-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Outlined Button
|
||||||
|
**Element**: `md-outlined-button` | **Import**: `@material/web/button/outlined-button.js`
|
||||||
|
**Use when**: Medium emphasis, neutral. Good for secondary actions.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-outlined-button>Cancel</md-outlined-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize**: `--md-outlined-button-outline-color`, `--md-outlined-button-outline-width`
|
||||||
|
|
||||||
|
#### Text Button
|
||||||
|
**Element**: `md-text-button` | **Import**: `@material/web/button/text-button.js`
|
||||||
|
**Use when**: Lowest emphasis. Inline actions, dialog actions, less important options.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-text-button>Learn more</md-text-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Button Sizes (Expressive)
|
||||||
|
Buttons now support 5 sizes: extra-small, small (default), medium, large, extra-large. Set via CSS:
|
||||||
|
```css
|
||||||
|
md-filled-button { --md-filled-button-container-height: 32px; } /* XS */
|
||||||
|
md-filled-button { --md-filled-button-container-height: 40px; } /* S (default) */
|
||||||
|
md-filled-button { --md-filled-button-container-height: 48px; } /* M */
|
||||||
|
md-filled-button { --md-filled-button-container-height: 56px; } /* L */
|
||||||
|
md-filled-button { --md-filled-button-container-height: 64px; } /* XL */
|
||||||
|
```
|
||||||
|
|
||||||
|
**A11y**: Buttons have built-in button role. Use `aria-label` when using icon-only buttons. Minimum touch target 48x48dp.
|
||||||
|
|
||||||
|
### Button Group
|
||||||
|
**Element**: `md-button-group` | **Import**: `@material/web/button/button-group.js`
|
||||||
|
**Use when**: Grouping related actions together with connected visual treatment.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-button-group>
|
||||||
|
<md-outlined-button>Day</md-outlined-button>
|
||||||
|
<md-outlined-button>Week</md-outlined-button>
|
||||||
|
<md-outlined-button>Month</md-outlined-button>
|
||||||
|
</md-button-group>
|
||||||
|
```
|
||||||
|
|
||||||
|
### FAB (Floating Action Button)
|
||||||
|
**Element**: `md-fab` | **Import**: `@material/web/fab/fab.js`
|
||||||
|
**Use when**: The single most important action on a screen.
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `size` | string | `small`, `medium` (default), `large` |
|
||||||
|
| `variant` | string | `surface`, `primary`, `secondary`, `tertiary` |
|
||||||
|
| `label` | string | Text label (for extended FAB) |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-fab aria-label="Create new">
|
||||||
|
<md-icon slot="icon">add</md-icon>
|
||||||
|
</md-fab>
|
||||||
|
|
||||||
|
<md-fab size="small" variant="tertiary" aria-label="Edit">
|
||||||
|
<md-icon slot="icon">edit</md-icon>
|
||||||
|
</md-fab>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize**: `--md-fab-container-color`, `--md-fab-container-shape`, `--md-fab-icon-color`
|
||||||
|
**A11y**: Always provide `aria-label` since FABs are icon-only.
|
||||||
|
|
||||||
|
### Extended FAB
|
||||||
|
**Element**: `md-extended-fab` | **Import**: `@material/web/fab/extended-fab.js`
|
||||||
|
**Use when**: Primary action with explanatory text.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-extended-fab label="New message">
|
||||||
|
<md-icon slot="icon">edit</md-icon>
|
||||||
|
</md-extended-fab>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Icon Button
|
||||||
|
**Element**: `md-icon-button` | **Import**: `@material/web/iconbutton/icon-button.js`
|
||||||
|
|
||||||
|
4 variants with separate elements:
|
||||||
|
|
||||||
|
| Variant | Element | Import |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| Standard | `md-icon-button` | `@material/web/iconbutton/icon-button.js` |
|
||||||
|
| Filled | `md-filled-icon-button` | `@material/web/iconbutton/filled-icon-button.js` |
|
||||||
|
| Filled Tonal | `md-filled-tonal-icon-button` | `@material/web/iconbutton/filled-tonal-icon-button.js` |
|
||||||
|
| Outlined | `md-outlined-icon-button` | `@material/web/iconbutton/outlined-icon-button.js` |
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `toggle` | boolean | Enables toggle behavior |
|
||||||
|
| `selected` | boolean | Selected state (when toggle) |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-icon-button aria-label="Settings">
|
||||||
|
<md-icon>settings</md-icon>
|
||||||
|
</md-icon-button>
|
||||||
|
|
||||||
|
<!-- Toggle icon button (like/unlike) -->
|
||||||
|
<md-icon-button toggle aria-label="Favorite">
|
||||||
|
<md-icon>favorite_border</md-icon>
|
||||||
|
<md-icon slot="selected">favorite</md-icon>
|
||||||
|
</md-icon-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**A11y**: Always provide `aria-label`. Toggle buttons should have descriptive labels for both states.
|
||||||
|
|
||||||
|
### Segmented Buttons
|
||||||
|
**No @material/web element yet.** Implement with standard HTML + MD3 tokens:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-segmented-buttons" role="group" aria-label="View options">
|
||||||
|
<button class="md3-segmented-button md3-segmented-button--selected" aria-pressed="true">
|
||||||
|
<md-icon>view_list</md-icon> List
|
||||||
|
</button>
|
||||||
|
<button class="md3-segmented-button" aria-pressed="false">
|
||||||
|
<md-icon>grid_view</md-icon> Grid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-segmented-buttons {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
|
border-radius: var(--md-sys-shape-corner-full);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.md3-segmented-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
font: var(--md-sys-typescale-label-large);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.md3-segmented-button--selected {
|
||||||
|
background: var(--md-sys-color-secondary-container);
|
||||||
|
color: var(--md-sys-color-on-secondary-container);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
### Badge
|
||||||
|
**No @material/web element yet.** Implement with CSS:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-badge-container">
|
||||||
|
<md-icon>notifications</md-icon>
|
||||||
|
<span class="md3-badge md3-badge--large">3</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-badge-container { position: relative; display: inline-flex; }
|
||||||
|
.md3-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
background: var(--md-sys-color-error);
|
||||||
|
color: var(--md-sys-color-on-error);
|
||||||
|
border-radius: var(--md-sys-shape-corner-full);
|
||||||
|
font: var(--md-sys-typescale-label-small);
|
||||||
|
}
|
||||||
|
.md3-badge--small { width: 6px; height: 6px; padding: 0; } /* dot only */
|
||||||
|
.md3-badge--large { min-width: 16px; height: 16px; padding: 0 4px; text-align: center; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Progress Indicator
|
||||||
|
**Elements**: `md-linear-progress`, `md-circular-progress`
|
||||||
|
**Import**: `@material/web/progress/linear-progress.js`, `@material/web/progress/circular-progress.js`
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `value` | number | Current progress (0–1) |
|
||||||
|
| `max` | number | Maximum value (default 1) |
|
||||||
|
| `indeterminate` | boolean | Shows indeterminate animation |
|
||||||
|
| `four-color` | boolean | Four-color indeterminate variant |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Determinate -->
|
||||||
|
<md-linear-progress value="0.6"></md-linear-progress>
|
||||||
|
<md-circular-progress value="0.75"></md-circular-progress>
|
||||||
|
|
||||||
|
<!-- Indeterminate -->
|
||||||
|
<md-linear-progress indeterminate></md-linear-progress>
|
||||||
|
<md-circular-progress indeterminate></md-circular-progress>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize**: `--md-linear-progress-active-indicator-color`, `--md-circular-progress-active-indicator-color`
|
||||||
|
**A11y**: Has built-in `progressbar` role. Add `aria-label` for context (e.g., "Loading messages").
|
||||||
|
|
||||||
|
### Snackbar
|
||||||
|
**No @material/web element yet.** Implement with standard HTML:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-snackbar" role="status" aria-live="polite">
|
||||||
|
<span class="md3-snackbar__text">Message sent</span>
|
||||||
|
<md-text-button class="md3-snackbar__action">Undo</md-text-button>
|
||||||
|
<md-icon-button class="md3-snackbar__close" aria-label="Dismiss">
|
||||||
|
<md-icon>close</md-icon>
|
||||||
|
</md-icon-button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-snackbar {
|
||||||
|
background: var(--md-sys-color-inverse-surface);
|
||||||
|
color: var(--md-sys-color-inverse-on-surface);
|
||||||
|
border-radius: var(--md-sys-shape-corner-extra-small);
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 48px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tooltip
|
||||||
|
**No @material/web element yet.**
|
||||||
|
|
||||||
|
Two types:
|
||||||
|
- **Plain**: Short text label, appears on hover/focus. Use for icon buttons and truncated text.
|
||||||
|
- **Rich**: Multi-line with optional actions. Use for complex explanations.
|
||||||
|
|
||||||
|
## Containment
|
||||||
|
|
||||||
|
### Card
|
||||||
|
**No @material/web element yet.** Three variants:
|
||||||
|
|
||||||
|
| Variant | Appearance | Elevation |
|
||||||
|
|---------|-----------|-----------|
|
||||||
|
| Filled | Surface-container-highest fill, no border | Level 0 |
|
||||||
|
| Outlined | Surface fill, outline-variant border | Level 0 |
|
||||||
|
| Elevated | Surface-container-low fill, shadow | Level 1 |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Outlined card -->
|
||||||
|
<div class="md3-card md3-card--outlined">
|
||||||
|
<div class="md3-card__content">
|
||||||
|
<h3 style="font: var(--md-sys-typescale-title-medium)">Title</h3>
|
||||||
|
<p style="font: var(--md-sys-typescale-body-medium); color: var(--md-sys-color-on-surface-variant)">
|
||||||
|
Supporting text
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="md3-card__actions">
|
||||||
|
<md-filled-tonal-button>Action</md-filled-tonal-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filled card -->
|
||||||
|
<div class="md3-card md3-card--filled">...</div>
|
||||||
|
|
||||||
|
<!-- Elevated card -->
|
||||||
|
<div class="md3-card md3-card--elevated">...</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-card {
|
||||||
|
border-radius: var(--md-sys-shape-corner-medium, 12px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.md3-card--outlined {
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
border: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
}
|
||||||
|
.md3-card--filled {
|
||||||
|
background: var(--md-sys-color-surface-container-highest);
|
||||||
|
}
|
||||||
|
.md3-card--elevated {
|
||||||
|
background: var(--md-sys-color-surface-container-low);
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.3), 0 1px 3px 1px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.md3-card__content { padding: 16px; }
|
||||||
|
.md3-card__actions { padding: 16px; display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dialog
|
||||||
|
**Element**: `md-dialog` | **Import**: `@material/web/dialog/dialog.js`
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `open` | boolean | Shows the dialog |
|
||||||
|
| `type` | string | `alert` (default) |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-dialog id="confirm-dialog">
|
||||||
|
<div slot="headline">Confirm action</div>
|
||||||
|
<form slot="content" method="dialog">
|
||||||
|
Are you sure you want to proceed?
|
||||||
|
</form>
|
||||||
|
<div slot="actions">
|
||||||
|
<md-text-button form="confirm-dialog" value="cancel">Cancel</md-text-button>
|
||||||
|
<md-filled-tonal-button form="confirm-dialog" value="confirm">Confirm</md-filled-tonal-button>
|
||||||
|
</div>
|
||||||
|
</md-dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
**A11y**: Dialog traps focus automatically. Use `slot="headline"` for accessible title.
|
||||||
|
|
||||||
|
### Bottom Sheet
|
||||||
|
**No @material/web element yet.** Two variants: Standard (persistent, coexists with content) and Modal (blocks interaction, has scrim).
|
||||||
|
|
||||||
|
### Side Sheet
|
||||||
|
**No @material/web element yet.** Two variants: Standard (docked alongside content) and Modal (overlays content with scrim).
|
||||||
|
|
||||||
|
### Divider
|
||||||
|
**Element**: `md-divider` | **Import**: `@material/web/divider/divider.js`
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `inset` | boolean | Adds inset on both sides |
|
||||||
|
| `inset-start` | boolean | Adds inset on start side |
|
||||||
|
| `inset-end` | boolean | Adds inset on end side |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-divider></md-divider>
|
||||||
|
<md-divider inset></md-divider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Carousel
|
||||||
|
**No @material/web element yet.** Three configurations:
|
||||||
|
- **Multi-browse**: Multiple items visible, scrollable
|
||||||
|
- **Uncontained**: Items extend beyond viewport edges
|
||||||
|
- **Hero**: One large featured item with smaller previews
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
### Checkbox
|
||||||
|
**Element**: `md-checkbox` | **Import**: `@material/web/checkbox/checkbox.js`
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `checked` | boolean | Checked state |
|
||||||
|
| `indeterminate` | boolean | Indeterminate state |
|
||||||
|
| `disabled` | boolean | Disabled state |
|
||||||
|
| `required` | boolean | Required for form validation |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<label>
|
||||||
|
<md-checkbox></md-checkbox>
|
||||||
|
Accept terms
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<md-checkbox checked></md-checkbox>
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
**A11y**: Wrap in `<label>` or use `aria-label`. Checkbox has built-in checkbox role.
|
||||||
|
|
||||||
|
### Chips
|
||||||
|
**Elements**: `md-chip-set`, `md-assist-chip`, `md-filter-chip`, `md-input-chip`, `md-suggestion-chip`
|
||||||
|
**Import**: `@material/web/chips/*.js`
|
||||||
|
|
||||||
|
| Variant | Element | Use |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| Assist | `md-assist-chip` | Smart suggestions, shortcuts |
|
||||||
|
| Filter | `md-filter-chip` | Filtering content, multi-select |
|
||||||
|
| Input | `md-input-chip` | User input tokens (email recipients) |
|
||||||
|
| Suggestion | `md-suggestion-chip` | Suggested responses, queries |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-chip-set>
|
||||||
|
<md-filter-chip label="Vegetarian" selected></md-filter-chip>
|
||||||
|
<md-filter-chip label="Vegan"></md-filter-chip>
|
||||||
|
<md-filter-chip label="Gluten-free"></md-filter-chip>
|
||||||
|
</md-chip-set>
|
||||||
|
|
||||||
|
<md-chip-set>
|
||||||
|
<md-input-chip label="user@example.com" removable></md-input-chip>
|
||||||
|
</md-chip-set>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu
|
||||||
|
**Elements**: `md-menu`, `md-menu-item`, `md-sub-menu`
|
||||||
|
**Import**: `@material/web/menu/menu.js`, `@material/web/menu/menu-item.js`
|
||||||
|
|
||||||
|
**I/O 2026 note:** Expressive menus have updated Material guidance for more flexible, vibrant configurations. In Jetpack Compose, prefer current Material3 menu APIs and expressive variants when available in your BOM. On web, use `md-menu` / `md-menu-item` for token-backed menus, or build custom spec-aligned variants when expressive behavior is required.
|
||||||
|
|
||||||
|
| Attribute (menu) | Type | Description |
|
||||||
|
|-----------------|------|-------------|
|
||||||
|
| `anchor` | string | ID of the anchor element |
|
||||||
|
| `open` | boolean | Shows the menu |
|
||||||
|
| `positioning` | string | `absolute`, `fixed`, `popover` |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span style="position: relative;">
|
||||||
|
<md-filled-button id="menu-trigger">Options</md-filled-button>
|
||||||
|
<md-menu id="options-menu" anchor="menu-trigger">
|
||||||
|
<md-menu-item>
|
||||||
|
<div slot="headline">Edit</div>
|
||||||
|
<md-icon slot="start">edit</md-icon>
|
||||||
|
</md-menu-item>
|
||||||
|
<md-menu-item>
|
||||||
|
<div slot="headline">Delete</div>
|
||||||
|
<md-icon slot="start">delete</md-icon>
|
||||||
|
</md-menu-item>
|
||||||
|
</md-menu>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('menu-trigger').addEventListener('click', () => {
|
||||||
|
document.getElementById('options-menu').open = !document.getElementById('options-menu').open;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Radio Button
|
||||||
|
**Element**: `md-radio` | **Import**: `@material/web/radio/radio.js`
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div role="radiogroup" aria-label="Size">
|
||||||
|
<label><md-radio name="size" value="s"></md-radio> Small</label>
|
||||||
|
<label><md-radio name="size" value="m" checked></md-radio> Medium</label>
|
||||||
|
<label><md-radio name="size" value="l"></md-radio> Large</label>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**A11y**: Group radios in a container with `role="radiogroup"` and `aria-label`.
|
||||||
|
|
||||||
|
### Slider
|
||||||
|
**Element**: `md-slider` | **Import**: `@material/web/slider/slider.js`
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `value` | number | Current value |
|
||||||
|
| `min` | number | Minimum value |
|
||||||
|
| `max` | number | Maximum value |
|
||||||
|
| `step` | number | Step increment (makes discrete) |
|
||||||
|
| `labeled` | boolean | Shows value label |
|
||||||
|
| `range` | boolean | Enables range selection |
|
||||||
|
| `value-start` | number | Start value (range mode) |
|
||||||
|
| `value-end` | number | End value (range mode) |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Continuous -->
|
||||||
|
<md-slider value="50" min="0" max="100" aria-label="Volume"></md-slider>
|
||||||
|
|
||||||
|
<!-- Discrete with label -->
|
||||||
|
<md-slider value="3" min="1" max="10" step="1" labeled aria-label="Rating"></md-slider>
|
||||||
|
|
||||||
|
<!-- Range -->
|
||||||
|
<md-slider range value-start="20" value-end="80" min="0" max="100" aria-label="Price range"></md-slider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Switch
|
||||||
|
**Element**: `md-switch` | **Import**: `@material/web/switch/switch.js`
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `selected` | boolean | On state |
|
||||||
|
| `icons` | boolean | Show on/off icons |
|
||||||
|
| `disabled` | boolean | Disabled state |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<label>
|
||||||
|
<md-switch></md-switch>
|
||||||
|
Dark mode
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<md-switch selected icons></md-switch>
|
||||||
|
Notifications
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize**: `--md-switch-selected-handle-color`, `--md-switch-selected-track-color`
|
||||||
|
|
||||||
|
### Text Field
|
||||||
|
**Elements**: `md-filled-text-field`, `md-outlined-text-field`
|
||||||
|
**Import**: `@material/web/textfield/filled-text-field.js`, `@material/web/textfield/outlined-text-field.js`
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `label` | string | Label text |
|
||||||
|
| `value` | string | Current value |
|
||||||
|
| `type` | string | Input type (text, email, password, number, textarea, etc.) |
|
||||||
|
| `placeholder` | string | Placeholder text |
|
||||||
|
| `required` | boolean | Required validation |
|
||||||
|
| `disabled` | boolean | Disabled state |
|
||||||
|
| `error` | boolean | Error state |
|
||||||
|
| `error-text` | string | Error message |
|
||||||
|
| `supporting-text` | string | Helper text |
|
||||||
|
| `prefix-text` | string | Prefix text |
|
||||||
|
| `suffix-text` | string | Suffix text |
|
||||||
|
| `max-length` | number | Character limit (shows counter) |
|
||||||
|
| `rows` | number | Rows (for textarea) |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Outlined (recommended for most uses) -->
|
||||||
|
<md-outlined-text-field
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
supporting-text="We'll never share your email">
|
||||||
|
</md-outlined-text-field>
|
||||||
|
|
||||||
|
<!-- Filled -->
|
||||||
|
<md-filled-text-field
|
||||||
|
label="Search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type to search...">
|
||||||
|
<md-icon slot="leading-icon">search</md-icon>
|
||||||
|
</md-filled-text-field>
|
||||||
|
|
||||||
|
<!-- With error -->
|
||||||
|
<md-outlined-text-field
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
error
|
||||||
|
error-text="Password must be at least 8 characters"
|
||||||
|
min-length="8">
|
||||||
|
</md-outlined-text-field>
|
||||||
|
|
||||||
|
<!-- Textarea -->
|
||||||
|
<md-outlined-text-field
|
||||||
|
label="Message"
|
||||||
|
type="textarea"
|
||||||
|
rows="4"
|
||||||
|
max-length="500">
|
||||||
|
</md-outlined-text-field>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize**: `--md-outlined-text-field-container-shape`, `--md-outlined-text-field-focus-outline-color`, `--md-filled-text-field-container-color`
|
||||||
|
|
||||||
|
#### Jetpack Compose
|
||||||
|
|
||||||
|
Use **`OutlinedTextField`** / **`TextField`** from **`androidx.compose.material3`**. Prefer **state-based** APIs (`TextFieldState`, `rememberTextFieldState()`) when targeting current Material3 releases — see the [package overview](https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary). Map labels, supporting text, and error state to MD3 roles (`MaterialTheme.colorScheme`, `TextFieldDefaults`).
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Illustrative — API names vary slightly by Material3 version
|
||||||
|
val state = rememberTextFieldState("")
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
state = state,
|
||||||
|
label = { Text("Email") },
|
||||||
|
supportingText = { if (isError) Text("Invalid email") },
|
||||||
|
isError = isError,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Picker
|
||||||
|
**No @material/web element yet.** Three configurations:
|
||||||
|
- **Docked**: Inline calendar attached to input
|
||||||
|
- **Modal**: Full dialog for date selection
|
||||||
|
- **Range**: Select a date range
|
||||||
|
|
||||||
|
### Time Picker
|
||||||
|
**No @material/web element yet.** Two configurations:
|
||||||
|
- **Docked**: Inline time input
|
||||||
|
- **Modal**: Clock dial in dialog
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### App Bar (Top)
|
||||||
|
**No @material/web element yet.** Four variants:
|
||||||
|
|
||||||
|
| Variant | Height | Title position | Scroll behavior |
|
||||||
|
|---------|--------|---------------|----------------|
|
||||||
|
| Center-aligned | 64dp | Center | Elevate on scroll |
|
||||||
|
| Small | 64dp | Left | Elevate on scroll |
|
||||||
|
| Medium | 112dp | Left, bottom | Collapse to small on scroll |
|
||||||
|
| Large | 152dp | Left, bottom | Collapse to small on scroll |
|
||||||
|
|
||||||
|
**Jetpack Compose:** `TopAppBar`, `CenterAlignedTopAppBar`, `MediumTopAppBar`, `LargeTopAppBar`, and expressive variants (e.g. large flexible) may require **`@OptIn(ExperimentalMaterial3ExpressiveApi::class)`** depending on BOM — check your `material3` version.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<header class="md3-top-app-bar md3-top-app-bar--small">
|
||||||
|
<md-icon-button aria-label="Menu"><md-icon>menu</md-icon></md-icon-button>
|
||||||
|
<span class="md3-top-app-bar__title" style="font: var(--md-sys-typescale-title-large)">
|
||||||
|
Page Title
|
||||||
|
</span>
|
||||||
|
<md-icon-button aria-label="Search"><md-icon>search</md-icon></md-icon-button>
|
||||||
|
<md-icon-button aria-label="More"><md-icon>more_vert</md-icon></md-icon-button>
|
||||||
|
</header>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-top-app-bar {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
.md3-top-app-bar__title { flex: 1; padding: 0 12px; }
|
||||||
|
/* Scrolled state */
|
||||||
|
.md3-top-app-bar--scrolled { background: var(--md-sys-color-surface-container); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation Bar
|
||||||
|
**Element**: `md-navigation-bar` | **Import**: `@material/web/navigation/navigation-bar.js`
|
||||||
|
**Use when**: 3–5 primary destinations, mobile/compact screens, persistent.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-navigation-bar>
|
||||||
|
<md-navigation-tab label="Home" active>
|
||||||
|
<md-icon slot="active-icon">home</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">home</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
<md-navigation-tab label="Explore">
|
||||||
|
<md-icon slot="active-icon">explore</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">explore</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
<md-navigation-tab label="Profile">
|
||||||
|
<md-icon slot="active-icon">person</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">person</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
</md-navigation-bar>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation Drawer
|
||||||
|
**Element**: `md-navigation-drawer` | **Import**: `@material/web/navigation/navigation-drawer.js`
|
||||||
|
**Use when**: Many destinations, larger screens, can be modal or persistent.
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `opened` | boolean | Open state |
|
||||||
|
| `type` | string | `standard` or `modal` |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-navigation-drawer opened>
|
||||||
|
<div slot="headline">Mail</div>
|
||||||
|
<md-list>
|
||||||
|
<md-list-item type="button" active>
|
||||||
|
<md-icon slot="start">inbox</md-icon>
|
||||||
|
Inbox
|
||||||
|
</md-list-item>
|
||||||
|
<md-list-item type="button">
|
||||||
|
<md-icon slot="start">send</md-icon>
|
||||||
|
Sent
|
||||||
|
</md-list-item>
|
||||||
|
</md-list>
|
||||||
|
</md-navigation-drawer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation Rail
|
||||||
|
**No @material/web element yet.** Use when: 3–7 destinations, medium screens (600–839dp), persistent side navigation.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<nav class="md3-nav-rail" aria-label="Main">
|
||||||
|
<md-fab size="small" variant="tertiary" aria-label="Compose">
|
||||||
|
<md-icon slot="icon">edit</md-icon>
|
||||||
|
</md-fab>
|
||||||
|
<div class="md3-nav-rail__items">
|
||||||
|
<a href="/" class="md3-nav-rail__item md3-nav-rail__item--active" aria-current="page">
|
||||||
|
<md-icon>home</md-icon>
|
||||||
|
<span>Home</span>
|
||||||
|
</a>
|
||||||
|
<a href="/search" class="md3-nav-rail__item">
|
||||||
|
<md-icon>search</md-icon>
|
||||||
|
<span>Search</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-nav-rail {
|
||||||
|
width: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
}
|
||||||
|
.md3-nav-rail__items { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.md3-nav-rail__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font: var(--md-sys-typescale-label-medium);
|
||||||
|
}
|
||||||
|
.md3-nav-rail__item--active { color: var(--md-sys-color-on-surface); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search
|
||||||
|
**No @material/web element yet.** Two patterns:
|
||||||
|
- **Search bar**: Persistent search field in top app bar area
|
||||||
|
- **Search view**: Expandable search overlay with suggestions
|
||||||
|
|
||||||
|
**I/O 2026 note:** Expressive search and search app bar guidance adds a refreshed visual style, motion, and more flexible trailing icon behavior. In Jetpack Compose, use the current Material3 search APIs and expressive app bar variants where available. On web, implement search as a custom component using MD3 shape, color, spacing, and motion tokens.
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
**Elements**: `md-tabs`, `md-primary-tab`, `md-secondary-tab`
|
||||||
|
**Import**: `@material/web/tabs/tabs.js`, `@material/web/tabs/primary-tab.js`, `@material/web/tabs/secondary-tab.js`
|
||||||
|
|
||||||
|
| Variant | Element | Use |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| Primary | `md-primary-tab` | Top-level navigation within a page |
|
||||||
|
| Secondary | `md-secondary-tab` | Sub-sections within primary tabs |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-tabs>
|
||||||
|
<md-primary-tab active>
|
||||||
|
<md-icon slot="icon">flight</md-icon>
|
||||||
|
Flights
|
||||||
|
</md-primary-tab>
|
||||||
|
<md-primary-tab>
|
||||||
|
<md-icon slot="icon">hotel</md-icon>
|
||||||
|
Hotels
|
||||||
|
</md-primary-tab>
|
||||||
|
<md-primary-tab>
|
||||||
|
<md-icon slot="icon">explore</md-icon>
|
||||||
|
Explore
|
||||||
|
</md-primary-tab>
|
||||||
|
</md-tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
**A11y**: Tab set has built-in tablist role. Connect tabs to panels with `aria-controls`.
|
||||||
|
|
||||||
|
### Toolbar
|
||||||
|
**No @material/web element yet.** Displays frequently used actions relevant to current page context. Typically placed below top app bar or in contextual positions.
|
||||||
|
|
||||||
|
## Data Display
|
||||||
|
|
||||||
|
### List
|
||||||
|
**Elements**: `md-list`, `md-list-item`
|
||||||
|
**Import**: `@material/web/list/list.js`, `@material/web/list/list-item.js`
|
||||||
|
|
||||||
|
**I/O 2026 note:** Expressive lists add more vibrant styles and flexible item configurations. In Compose, prefer Material3 list patterns and keep spacing, leading/trailing content, supporting text, and dividers token-driven. On web, `md-list` is useful for standard lists; custom CSS may be needed for expressive list treatments.
|
||||||
|
|
||||||
|
| Attribute (list-item) | Type | Description |
|
||||||
|
|----------------------|------|-------------|
|
||||||
|
| `type` | string | `text` (default), `button`, `link` |
|
||||||
|
| `href` | string | URL (when type="link") |
|
||||||
|
| `disabled` | boolean | Disabled state |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-list>
|
||||||
|
<!-- One-line -->
|
||||||
|
<md-list-item>Single line item</md-list-item>
|
||||||
|
|
||||||
|
<!-- Two-line with icon -->
|
||||||
|
<md-list-item>
|
||||||
|
<md-icon slot="start">person</md-icon>
|
||||||
|
<div slot="headline">Jane Smith</div>
|
||||||
|
<div slot="supporting-text">Senior Developer</div>
|
||||||
|
</md-list-item>
|
||||||
|
|
||||||
|
<!-- Three-line -->
|
||||||
|
<md-list-item>
|
||||||
|
<md-icon slot="start">mail</md-icon>
|
||||||
|
<div slot="headline">Meeting notes</div>
|
||||||
|
<div slot="supporting-text">Please review the attached notes from today's standup meeting and provide feedback.</div>
|
||||||
|
<div slot="trailing-supporting-text">3 min ago</div>
|
||||||
|
</md-list-item>
|
||||||
|
|
||||||
|
<md-divider></md-divider>
|
||||||
|
|
||||||
|
<!-- Clickable item -->
|
||||||
|
<md-list-item type="button" onclick="handleClick()">
|
||||||
|
<md-icon slot="start">settings</md-icon>
|
||||||
|
<div slot="headline">Settings</div>
|
||||||
|
<md-icon slot="end">chevron_right</md-icon>
|
||||||
|
</md-list-item>
|
||||||
|
</md-list>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Slots**: `start` (leading element), `end` (trailing element), `headline` (primary text), `supporting-text` (secondary text), `trailing-supporting-text` (trailing metadata), `overline` (above headline)
|
||||||
733
.agents/skills/material-3/references/layout-and-responsive.md
Normal file
733
.agents/skills/material-3/references/layout-and-responsive.md
Normal file
@@ -0,0 +1,733 @@
|
|||||||
|
# MD3 Layout and Responsive Design
|
||||||
|
|
||||||
|
Reference for Material Design 3's layout system: breakpoints, canonical layouts, and responsive implementation.
|
||||||
|
|
||||||
|
## Jetpack Compose and Android
|
||||||
|
|
||||||
|
The **breakpoint table** below is a **design** reference. In **Jetpack Compose**, prefer **`calculateWindowSizeClass`** (`androidx.compose.material3:material3-window-size-class`) and/or **`androidx.compose.material3.adaptive`** APIs (e.g. `currentWindowAdaptiveInfo`, list-detail scaffolds) instead of hand-rolling raw `BoxWithConstraints` width checks everywhere.
|
||||||
|
|
||||||
|
**Edge-to-edge:** Use **`enableEdgeToEdge()`** on your `Activity` (AndroidX) when you draw behind the system bars. Apply **`WindowInsets`** (`Modifier.statusBarsPadding()`, `navigationBarsPadding()`, **`imePadding()`**, `displayCutoutPadding()`, etc.) and **`Scaffold`** `contentWindowInsets` so content and **IME** behave correctly.
|
||||||
|
|
||||||
|
**Foldables:** Use **`WindowInfoTracker`**, **`FoldingFeature`**, or Jetpack WindowManager APIs — see the foldable section below; verify APIs against your dependency versions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Google I/O 2026: Expressive Layout
|
||||||
|
|
||||||
|
Material's [I/O 2026 update](https://m3.material.io/blog/whats-new-at-io26) introduced broader expressive/adaptive layout guidance:
|
||||||
|
|
||||||
|
- **Expressive layout scaffold**: Design screens to adapt across mobile, desktop, foldables, watches, XR, and other spatial form factors. In Compose, prefer Material3 adaptive scaffolds and window size classes over hard-coded phone/tablet branches.
|
||||||
|
- **8dp spacing system**: Treat spacing as tokens. Use an 8dp scale for margins, padding, gaps, and component spacing so density and device-class changes can be applied consistently.
|
||||||
|
- **Watch guidance**: Use physics-based motion, arc text styles, and edge-hugging containers. Avoid shrinking phone layouts onto a round or compact wearable screen.
|
||||||
|
- **XR guidance**: Use spatial panels and depth-based elevation. Avoid treating XR as only a larger desktop canvas; account for depth, comfort, and panel placement.
|
||||||
|
|
||||||
|
### Spacing Token Pattern
|
||||||
|
|
||||||
|
Define spacing once, then map it to context:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object MdSpacing {
|
||||||
|
val xxs = 4.dp
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use spacing tokens for:
|
||||||
|
- Screen margins
|
||||||
|
- Pane gaps
|
||||||
|
- List item spacing
|
||||||
|
- Card internal padding
|
||||||
|
- Component groups and toolbars
|
||||||
|
|
||||||
|
Do not scatter raw `Dp` literals throughout reusable UI. Keep one-off values local only when they are truly component-specific.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Window Size Classes
|
||||||
|
|
||||||
|
MD3 defines 5 breakpoint classes:
|
||||||
|
|
||||||
|
| Class | Width Range | Typical Devices | Columns |
|
||||||
|
|-------|-----------|----------------|---------|
|
||||||
|
| Compact | < 600dp | Phone portrait | 4 |
|
||||||
|
| Medium | 600–839dp | Tablet portrait, foldable | 8 |
|
||||||
|
| Expanded | 840–1199dp | Tablet landscape, small desktop | 12 |
|
||||||
|
| Large | 1200–1599dp | Desktop | 12 |
|
||||||
|
| Extra-large | 1600dp+ | Ultra-wide, large desktop | 12 |
|
||||||
|
|
||||||
|
### CSS Media Queries (web)
|
||||||
|
|
||||||
|
Use these for **CSS layouts**. **Compose** apps should use window size classes / adaptive APIs rather than duplicating this logic only in CSS.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Compact (default — mobile-first) */
|
||||||
|
/* No media query needed, this is the base */
|
||||||
|
|
||||||
|
/* Medium */
|
||||||
|
@media (min-width: 600px) { }
|
||||||
|
|
||||||
|
/* Expanded */
|
||||||
|
@media (min-width: 840px) { }
|
||||||
|
|
||||||
|
/* Large */
|
||||||
|
@media (min-width: 1200px) { }
|
||||||
|
|
||||||
|
/* Extra-large */
|
||||||
|
@media (min-width: 1600px) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### dp to px Conversion
|
||||||
|
On the web, 1dp ≈ 1px at standard density. The breakpoint values translate directly to CSS pixels.
|
||||||
|
|
||||||
|
## Layout Anatomy
|
||||||
|
|
||||||
|
### Key Terms
|
||||||
|
|
||||||
|
- **Window**: The visible area of the app
|
||||||
|
- **Pane**: A layout container within the window. A pane is fixed, flexible, floating, or semi-permanent
|
||||||
|
- **Column**: A vertical content block within a pane
|
||||||
|
- **Margin**: Space between screen edge and content
|
||||||
|
- **Gutter**: Space between columns
|
||||||
|
- **Spacer**: Space between panes (in multi-pane layouts)
|
||||||
|
|
||||||
|
### Margin and Gutter Values
|
||||||
|
|
||||||
|
| Window Size | Margins | Gutters |
|
||||||
|
|-------------|---------|---------|
|
||||||
|
| Compact | 16dp | 8dp |
|
||||||
|
| Medium | 24dp | 16dp |
|
||||||
|
| Expanded | 24dp | 16dp |
|
||||||
|
| Large | 24dp | 24dp |
|
||||||
|
| Extra-large | 24dp | 24dp |
|
||||||
|
|
||||||
|
## Canonical Layouts
|
||||||
|
|
||||||
|
MD3 defines 3 canonical layouts as starting points. Always begin from one of these rather than from a raw grid.
|
||||||
|
|
||||||
|
### Feed Layout
|
||||||
|
|
||||||
|
**Use when**: Displaying a large collection of browsable items (social feed, news, product grid).
|
||||||
|
|
||||||
|
```
|
||||||
|
Compact: Single column of cards
|
||||||
|
Medium: 2-column grid
|
||||||
|
Expanded: 3-column grid
|
||||||
|
Large: 4-column grid + optional side panel
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-feed">
|
||||||
|
<div class="md3-feed__item">
|
||||||
|
<!-- Card content -->
|
||||||
|
</div>
|
||||||
|
<div class="md3-feed__item">
|
||||||
|
<!-- Card content -->
|
||||||
|
</div>
|
||||||
|
<!-- More items -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-feed {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
grid-template-columns: 1fr; /* Compact: 1 column */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.md3-feed {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 840px) {
|
||||||
|
.md3-feed {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.md3-feed {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List-Detail Layout
|
||||||
|
|
||||||
|
**Use when**: Browsing a list of items where each has detailed content (email, file browser, contacts).
|
||||||
|
|
||||||
|
```
|
||||||
|
Compact: List view OR detail view (navigate between them)
|
||||||
|
Medium: Side-by-side list (1/3) + detail (2/3)
|
||||||
|
Expanded: Side-by-side with wider detail pane
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-list-detail">
|
||||||
|
<aside class="md3-list-detail__list">
|
||||||
|
<md-list>
|
||||||
|
<md-list-item type="button" class="active">
|
||||||
|
<div slot="headline">Item 1</div>
|
||||||
|
<div slot="supporting-text">Description</div>
|
||||||
|
</md-list-item>
|
||||||
|
<md-list-item type="button">
|
||||||
|
<div slot="headline">Item 2</div>
|
||||||
|
<div slot="supporting-text">Description</div>
|
||||||
|
</md-list-item>
|
||||||
|
</md-list>
|
||||||
|
</aside>
|
||||||
|
<main class="md3-list-detail__detail">
|
||||||
|
<h2>Item 1 Detail</h2>
|
||||||
|
<p>Full content here...</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-list-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-list-detail__list {
|
||||||
|
background: var(--md-sys-color-surface-container);
|
||||||
|
border-radius: var(--md-sys-shape-corner-large);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-list-detail__detail {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact: show one at a time */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.md3-list-detail__detail { display: none; }
|
||||||
|
.md3-list-detail--detail-active .md3-list-detail__list { display: none; }
|
||||||
|
.md3-list-detail--detail-active .md3-list-detail__detail { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Medium+: side by side */
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.md3-list-detail {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.md3-list-detail__list {
|
||||||
|
width: 360px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded: wider detail */
|
||||||
|
@media (min-width: 840px) {
|
||||||
|
.md3-list-detail__list {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drag Handle for Resizable Panes
|
||||||
|
|
||||||
|
In list-detail and supporting pane layouts, users can resize panes with a drag handle:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-list-detail">
|
||||||
|
<aside class="md3-list-detail__list">...</aside>
|
||||||
|
<div class="md3-drag-handle" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
||||||
|
<main class="md3-list-detail__detail">...</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-drag-handle {
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.md3-drag-handle::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--md-sys-color-outline);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supporting Pane Layout
|
||||||
|
|
||||||
|
**Use when**: Primary content needs supplementary information (document + properties panel, video + comments).
|
||||||
|
|
||||||
|
```
|
||||||
|
Compact: Stacked — primary on top, supporting below (or bottom sheet)
|
||||||
|
Medium: Side-by-side (2/3 primary + 1/3 supporting)
|
||||||
|
Expanded: Same but with more space
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-supporting-pane">
|
||||||
|
<main class="md3-supporting-pane__primary">
|
||||||
|
<!-- Primary content (2/3) -->
|
||||||
|
</main>
|
||||||
|
<aside class="md3-supporting-pane__secondary">
|
||||||
|
<!-- Supporting content (1/3) -->
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-supporting-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Medium+: side by side */
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.md3-supporting-pane {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.md3-supporting-pane__primary { flex: 2; }
|
||||||
|
.md3-supporting-pane__secondary { flex: 1; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Container Queries
|
||||||
|
|
||||||
|
For component-level responsive behavior (independent of viewport), use container queries:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Define a container */
|
||||||
|
.md3-card-container {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respond to container width */
|
||||||
|
@container card (min-width: 400px) {
|
||||||
|
.md3-card {
|
||||||
|
flex-direction: row; /* Horizontal layout when container is wide */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container card (max-width: 399px) {
|
||||||
|
.md3-card {
|
||||||
|
flex-direction: column; /* Vertical layout when narrow */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adaptive Component Behavior
|
||||||
|
|
||||||
|
Components transform across breakpoints:
|
||||||
|
|
||||||
|
| Component | Compact | Medium (incl. foldable unfolded) | Expanded+ / Large screen |
|
||||||
|
|-----------|---------|--------|-----------|
|
||||||
|
| Navigation | Bottom bar | Side rail | Side drawer |
|
||||||
|
| App bar | Small (64dp) | Small (64dp) | Small or Medium (112dp) |
|
||||||
|
| Dialog | Full-screen | Centered dialog | Centered dialog (max 560dp wide) |
|
||||||
|
| Bottom sheet | Full height | Partial height | Side sheet |
|
||||||
|
| Search | Full-screen search view | Persistent search bar | Persistent search bar |
|
||||||
|
| Cards | Full-width single column | Multi-column grid | Multi-column grid (max 4 cols) |
|
||||||
|
| Content panes | Single pane | Optional second pane | Two or three panes |
|
||||||
|
| Input method | Touch only | Touch + stylus | Touch + mouse/trackpad + keyboard |
|
||||||
|
|
||||||
|
## Complete App Layout Example
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-app-layout">
|
||||||
|
<!-- Navigation (responsive — see navigation-patterns.md) -->
|
||||||
|
<nav class="md3-nav" aria-label="Main navigation">
|
||||||
|
<!-- Nav content varies by breakpoint -->
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main area -->
|
||||||
|
<div class="md3-main-area">
|
||||||
|
<!-- Top app bar -->
|
||||||
|
<header class="md3-top-app-bar">
|
||||||
|
<h1 class="md3-top-app-bar__title">Dashboard</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Content area with canonical layout -->
|
||||||
|
<main class="md3-content-area">
|
||||||
|
<!-- Use feed, list-detail, or supporting pane here -->
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-app-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-main-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0; /* Prevent flex overflow */
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-content-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact: stack vertically */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.md3-app-layout { flex-direction: column; }
|
||||||
|
.md3-content-area { padding: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Medium */
|
||||||
|
@media (min-width: 600px) and (max-width: 839px) {
|
||||||
|
.md3-content-area { padding: 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded+ */
|
||||||
|
@media (min-width: 840px) {
|
||||||
|
.md3-content-area { padding: 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large+: constrain max content width */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.md3-content-area {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Foldables and Large Screens
|
||||||
|
|
||||||
|
MD3 provides specific guidance for foldable devices, tablets, and large-screen form factors. These are first-class targets in Material Design 3 — not afterthoughts.
|
||||||
|
|
||||||
|
### Foldable Postures
|
||||||
|
|
||||||
|
Foldable devices introduce postures that don't exist on traditional phones:
|
||||||
|
|
||||||
|
| Posture | Description | Layout behavior |
|
||||||
|
|---------|-------------|----------------|
|
||||||
|
| **Flat (unfolded)** | Device fully open, single large screen | Treat as Medium or Expanded window class based on width |
|
||||||
|
| **Half-opened (tabletop)** | Folded ~90° horizontally, bottom half on table | Split content at the hinge — video/image on top half, controls/info on bottom half |
|
||||||
|
| **Half-opened (book)** | Folded ~90° vertically, held like a book | Split content at the hinge — list on one side, detail on the other |
|
||||||
|
| **Folded** | Device closed, outer/cover screen | Treat as Compact — show essential content only |
|
||||||
|
|
||||||
|
### Hinge-Aware Layouts
|
||||||
|
|
||||||
|
The fold/hinge is a physical divider. Never place interactive content or critical information across the hinge area.
|
||||||
|
|
||||||
|
**Web — CSS Viewport Segments API:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Detect a dual-screen / foldable device with two horizontal segments */
|
||||||
|
@media (horizontal-viewport-segments: 2) {
|
||||||
|
.md3-list-detail {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.md3-list-detail__list {
|
||||||
|
/* Span the left segment */
|
||||||
|
width: env(viewport-segment-width 0 0);
|
||||||
|
margin-right: env(viewport-segment-left 1 0, 0px) - env(viewport-segment-right 0 0, 0px);
|
||||||
|
}
|
||||||
|
.md3-list-detail__detail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detect tabletop posture (two vertical segments) */
|
||||||
|
@media (vertical-viewport-segments: 2) {
|
||||||
|
.md3-media-player {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.md3-media-player__video {
|
||||||
|
height: env(viewport-segment-height 0 0);
|
||||||
|
}
|
||||||
|
.md3-media-player__controls {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flutter — `MediaQuery` and display features:**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final displayFeatures = MediaQuery.of(context).displayFeatures;
|
||||||
|
final hinge = displayFeatures.whereType<DisplayFeature>().where(
|
||||||
|
(f) => f.type == DisplayFeatureType.hinge || f.type == DisplayFeatureType.fold,
|
||||||
|
).firstOrNull;
|
||||||
|
|
||||||
|
if (hinge != null) {
|
||||||
|
// Foldable device — split at the hinge
|
||||||
|
return TwoPane(
|
||||||
|
startPane: ListPane(),
|
||||||
|
endPane: DetailPane(),
|
||||||
|
paneProportion: 0.5,
|
||||||
|
panePriority: isPortrait ? TwoPanePriority.start : TwoPanePriority.both,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single screen — use window size class
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
if (width < 600) return CompactLayout();
|
||||||
|
if (width < 840) return MediumLayout();
|
||||||
|
return ExpandedLayout();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Jetpack Compose — `WindowInfoTracker` and `FoldingFeature`:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun AdaptiveLayout() {
|
||||||
|
val windowInfo = WindowInfoTracker.getOrCreate(LocalContext.current)
|
||||||
|
.windowLayoutInfo(LocalContext.current as Activity)
|
||||||
|
.collectAsState(initial = WindowLayoutInfo(emptyList()))
|
||||||
|
|
||||||
|
val foldingFeature = windowInfo.value.displayFeatures
|
||||||
|
.filterIsInstance<FoldingFeature>()
|
||||||
|
.firstOrNull()
|
||||||
|
|
||||||
|
when {
|
||||||
|
foldingFeature != null && foldingFeature.state == FoldingFeature.State.HALF_OPENED -> {
|
||||||
|
// Tabletop or book posture
|
||||||
|
if (foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL) {
|
||||||
|
TabletopLayout(foldingFeature) // top: content, bottom: controls
|
||||||
|
} else {
|
||||||
|
BookLayout(foldingFeature) // left: list, right: detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Standard adaptive layout based on window size class
|
||||||
|
val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity)
|
||||||
|
StandardAdaptiveLayout(windowSizeClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabletop Posture Pattern
|
||||||
|
|
||||||
|
When the device is in tabletop posture (horizontal fold, bottom half resting on a surface), the content naturally divides into two halves:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ │ ← Top half: visual content
|
||||||
|
│ Video / Image / │ (camera viewfinder, video player,
|
||||||
|
│ Primary content │ image gallery, map)
|
||||||
|
│ │
|
||||||
|
├─ ─ ─ hinge ─ ─ ─ ─ ┤
|
||||||
|
│ │ ← Bottom half: controls & info
|
||||||
|
│ Controls / Text / │ (playback controls, chat input,
|
||||||
|
│ Supporting info │ product details, toolbar)
|
||||||
|
│ │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Book Posture Pattern
|
||||||
|
|
||||||
|
When the device is in book posture (vertical fold, held like a book), it naturally maps to list-detail:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┬──────────┐
|
||||||
|
│ │ │
|
||||||
|
│ List / │ Detail │
|
||||||
|
│ Nav / │ Content │
|
||||||
|
│ Browse │ / Edit │
|
||||||
|
│ │ │
|
||||||
|
└──────────┴──────────┘
|
||||||
|
hinge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Large Screen Layout Guidance
|
||||||
|
|
||||||
|
For tablets, Chromebooks, desktop, and large foldables (Expanded, Large, Extra-large):
|
||||||
|
|
||||||
|
**Content width constraints:**
|
||||||
|
- Don't stretch content to fill ultra-wide screens — reading lines longer than ~80 characters become hard to scan
|
||||||
|
- Constrain body content to a max width (typically 840–1040dp) and center it
|
||||||
|
- Use the extra space for multi-pane layouts, not wider single columns
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Constrain content on large screens */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.md3-content-area {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-pane strategies by window class:**
|
||||||
|
|
||||||
|
| Window class | Columns | Recommended layout |
|
||||||
|
|-------------|---------|-------------------|
|
||||||
|
| Compact (<600dp) | 4 | Single pane. Full-screen navigation between views. |
|
||||||
|
| Medium (600–839dp) | 8 | Optional second pane. List-detail with narrow list. Rail navigation. |
|
||||||
|
| Expanded (840–1199dp) | 12 | Two panes standard. List-detail or supporting pane. Drawer navigation. |
|
||||||
|
| Large (1200–1599dp) | 12 | Two or three panes. Feed with side panel. Persistent supporting pane. |
|
||||||
|
| Extra-large (1600dp+) | 12 | Three panes or constrained two-pane with generous margins. |
|
||||||
|
|
||||||
|
**Input and interaction differences:**
|
||||||
|
- Large screens often have mouse/trackpad input — hover states and right-click menus matter
|
||||||
|
- Touch targets remain 48dp minimum but can be supplemented with hover tooltips
|
||||||
|
- Keyboard shortcuts become expected on desktop-class devices
|
||||||
|
- Drag-and-drop is more natural on large screens
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Add hover states for pointer devices */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.md3-card:hover {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--md-sys-color-on-surface) 8%,
|
||||||
|
var(--md-sys-color-surface)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-list-item:hover {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--md-sys-color-on-surface) 8%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure pointer-specific affordances */
|
||||||
|
@media (pointer: fine) {
|
||||||
|
/* Scrollbars, resize handles, tighter spacing are acceptable */
|
||||||
|
.md3-drag-handle { cursor: col-resize; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flutter — adaptive input:**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
final isLargeScreen = width >= 840;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
// Navigation adapts
|
||||||
|
if (isLargeScreen)
|
||||||
|
NavigationRail(
|
||||||
|
destinations: destinations,
|
||||||
|
selectedIndex: selectedIndex,
|
||||||
|
onDestinationSelected: onSelected,
|
||||||
|
labelType: NavigationRailLabelType.all,
|
||||||
|
leading: FloatingActionButton(
|
||||||
|
onPressed: onCompose,
|
||||||
|
child: const Icon(Icons.edit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Content fills remaining space
|
||||||
|
Expanded(
|
||||||
|
child: isLargeScreen
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 360, child: ListPane()),
|
||||||
|
const VerticalDivider(width: 1),
|
||||||
|
Expanded(child: DetailPane()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: selectedItem == null
|
||||||
|
? ListPane()
|
||||||
|
: DetailPane(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: isLargeScreen
|
||||||
|
? null
|
||||||
|
: NavigationBar(
|
||||||
|
destinations: destinations.map((d) =>
|
||||||
|
NavigationDestination(icon: d.icon, label: d.label)).toList(),
|
||||||
|
selectedIndex: selectedIndex,
|
||||||
|
onDestinationSelected: onSelected,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Foldable-Aware Canonical Layouts
|
||||||
|
|
||||||
|
The three canonical layouts adapt naturally to foldables:
|
||||||
|
|
||||||
|
| Layout | Foldable behavior |
|
||||||
|
|--------|------------------|
|
||||||
|
| **Feed** | Unfolded: multi-column grid fills both halves. Tabletop: grid on top, selected item preview on bottom. |
|
||||||
|
| **List-detail** | Book posture: list on left half, detail on right half — a perfect natural fit. Tabletop: list on top, detail on bottom. |
|
||||||
|
| **Supporting pane** | Book posture: primary on left, supporting on right. Tabletop: primary on top, supporting controls on bottom. |
|
||||||
|
|
||||||
|
### Testing Large Screens and Foldables
|
||||||
|
|
||||||
|
**Web:**
|
||||||
|
- Use Chrome DevTools responsive mode to test at 600, 840, 1200, and 1600px breakpoints
|
||||||
|
- Test with pointer: coarse (touch) and pointer: fine (mouse) media queries
|
||||||
|
- Verify content doesn't stretch beyond readable line lengths at 1600px+
|
||||||
|
|
||||||
|
**Flutter:**
|
||||||
|
- Use `DevicePreview` package to simulate foldables and tablets
|
||||||
|
- Test with `MediaQuery` overrides for `displayFeatures`
|
||||||
|
- Run on Android emulators: Pixel Fold, 7.6" foldable, 10" tablet, Chromebook
|
||||||
|
|
||||||
|
**Compose:**
|
||||||
|
- Use Android Studio foldable emulators (Pixel Fold, 7.6" Foldable)
|
||||||
|
- Test posture changes: flat → half-opened → folded
|
||||||
|
- Use `WindowInfoTracker` to verify fold-aware layout switching
|
||||||
|
|
||||||
|
### Audit Checklist for Foldable/Large Screen Support
|
||||||
|
|
||||||
|
When auditing, check these specific items:
|
||||||
|
|
||||||
|
- [ ] App uses `MediaQuery.sizeOf(context).width` or equivalent to determine window size class
|
||||||
|
- [ ] Layout switches from single-pane to multi-pane at 600dp
|
||||||
|
- [ ] Navigation transforms: bottom bar → rail → drawer across breakpoints
|
||||||
|
- [ ] Content has max-width constraint on large screens (not stretching to fill)
|
||||||
|
- [ ] No critical content or interactive elements placed across a fold/hinge
|
||||||
|
- [ ] Foldable postures handled (if targeting foldable devices): tabletop and book modes
|
||||||
|
- [ ] Hover states exist for pointer devices (`@media (hover: hover)`)
|
||||||
|
- [ ] Touch targets remain 48dp minimum even on large screens
|
||||||
|
- [ ] Dialogs are centered (not full-screen) on medium+ screens
|
||||||
|
- [ ] Bottom sheets convert to side sheets on expanded+ screens
|
||||||
|
|
||||||
|
## Spacing System
|
||||||
|
|
||||||
|
MD3 uses a 4dp base grid for spacing:
|
||||||
|
|
||||||
|
| Use | Values |
|
||||||
|
|-----|--------|
|
||||||
|
| Component internal padding | 4, 8, 12, 16, 24dp |
|
||||||
|
| Between components | 8, 12, 16, 24dp |
|
||||||
|
| Section spacing | 24, 32, 48dp |
|
||||||
|
| Layout margins | 16dp (compact), 24dp (medium+) |
|
||||||
|
| Grid gutters | 8dp (compact), 16dp (medium), 24dp (large+) |
|
||||||
|
|
||||||
|
Always use multiples of 4dp for consistent spatial rhythm.
|
||||||
546
.agents/skills/material-3/references/navigation-patterns.md
Normal file
546
.agents/skills/material-3/references/navigation-patterns.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# MD3 Navigation Patterns
|
||||||
|
|
||||||
|
Guide for choosing and implementing Material Design 3 navigation components.
|
||||||
|
|
||||||
|
## Jetpack Compose (primary)
|
||||||
|
|
||||||
|
Use **`androidx.compose.material3`**: `NavigationBar`, `NavigationRail`, `NavigationDrawerItem`, `ModalNavigationDrawer`, `DismissibleNavigationDrawer`, `PermanentNavigationDrawer`, `NavigationBarItem`, `NavigationRailItem`, top app bars (`TopAppBar`, `CenterAlignedTopAppBar`, `LargeTopAppBar`, expressive variants per BOM), and **`Scaffold`** (`bottomBar`, `floatingActionButton`, `snackbarHost`).
|
||||||
|
|
||||||
|
Wire destinations with **Navigation Compose** (`NavHost`, `composable`, `rememberNavController`). For **adaptive** UIs, use **`calculateWindowSizeClass`**, **`androidx.compose.material3.adaptive`**, or **`currentWindowAdaptiveInfo`** / **`NavigableListDetailPaneScaffold`** (names and packages depend on your BOM — check [Android Developers](https://developer.android.com/jetpack/androidx/releases/compose-material3)).
|
||||||
|
|
||||||
|
Material's [I/O 2026 update](https://m3.material.io/blog/whats-new-at-io26) adds expressive/adaptive emphasis:
|
||||||
|
|
||||||
|
- Prefer expressive/adaptive scaffolds for mobile, desktop, foldables, watches, and XR rather than scaling one phone navigation model upward.
|
||||||
|
- Expressive search and search app bars have refreshed visual style, motion, and more flexible trailing icon behavior. Use current Compose Material3 APIs where available; use web/CSS approximations only when targeting web.
|
||||||
|
- Keep navigation spacing on the 8dp spacing system so rail/drawer/app-bar gaps can adapt by device class and density.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Conceptual — adapt routes and selection to your app
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
destinations.forEach { dest ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentRoute == dest.route,
|
||||||
|
onClick = { navController.navigate(dest.route) },
|
||||||
|
icon = { Icon(dest.icon, contentDescription = dest.label) },
|
||||||
|
label = { Text(dest.label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = "home",
|
||||||
|
modifier = Modifier.padding(innerPadding)
|
||||||
|
) { /* composable routes */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web (limited):** The HTML/`@material/web` sections below remain useful for token-backed sites; [Material Web is maintenance-only](https://m3.material.io/develop/web).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation Component Selection
|
||||||
|
|
||||||
|
### Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
How many primary destinations?
|
||||||
|
├── 2 destinations → Tabs (primary)
|
||||||
|
├── 3–5 destinations
|
||||||
|
│ ├── Compact screen (<600dp) → Navigation Bar (bottom)
|
||||||
|
│ ├── Medium screen (600–839dp) → Navigation Rail (side)
|
||||||
|
│ └── Expanded+ screen (840dp+) → Navigation Drawer (side) or Rail
|
||||||
|
├── 6+ destinations
|
||||||
|
│ ├── Compact → Navigation Drawer (modal)
|
||||||
|
│ ├── Medium → Navigation Drawer (standard) or Rail + overflow menu
|
||||||
|
│ └── Expanded+ → Navigation Drawer (standard)
|
||||||
|
└── Hierarchical (nested sections)
|
||||||
|
└── Navigation Drawer with sections
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
| Component | Destinations | Screen Size | Persistence | Position |
|
||||||
|
|-----------|-------------|-------------|-------------|----------|
|
||||||
|
| Navigation Bar | 3–5 | Compact | Persistent | Bottom |
|
||||||
|
| Navigation Rail | 3–7 | Medium | Persistent | Side (start) |
|
||||||
|
| Navigation Drawer | Unlimited | Expanded+ | Standard or Modal | Side (start) |
|
||||||
|
| Tabs | 2+ related views | Any | Persistent | Top (below app bar) |
|
||||||
|
| Bottom App Bar | — (contextual actions) | Compact | Persistent | Bottom |
|
||||||
|
|
||||||
|
## Navigation Bar
|
||||||
|
|
||||||
|
**Use when**: 3–5 primary destinations on compact (mobile) screens.
|
||||||
|
**Position**: Bottom of screen, always visible.
|
||||||
|
|
||||||
|
### Anatomy
|
||||||
|
- Fixed at bottom, full width
|
||||||
|
- 3–5 navigation items with icon + label
|
||||||
|
- Active item shows filled icon + indicator pill
|
||||||
|
- Height: 80dp
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-navigation-bar active-index="0">
|
||||||
|
<md-navigation-tab label="Home" active-icon="home" inactive-icon="home">
|
||||||
|
<md-icon slot="active-icon">home</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">home</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
<md-navigation-tab label="Search">
|
||||||
|
<md-icon slot="active-icon">search</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">search</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
<md-navigation-tab label="Notifications">
|
||||||
|
<md-icon slot="active-icon">notifications</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">notifications</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
<md-navigation-tab label="Profile">
|
||||||
|
<md-icon slot="active-icon">person</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">person</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
</md-navigation-bar>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
```css
|
||||||
|
md-navigation-bar {
|
||||||
|
--md-navigation-bar-container-color: var(--md-sys-color-surface-container);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Guidelines
|
||||||
|
- Always show labels (don't use icon-only)
|
||||||
|
- Use filled icons for active state, outlined for inactive
|
||||||
|
- Don't use for fewer than 3 or more than 5 destinations
|
||||||
|
- Hide on scroll down in content-heavy screens (optional)
|
||||||
|
- Elevation level 2 (3dp)
|
||||||
|
|
||||||
|
## Navigation Rail
|
||||||
|
|
||||||
|
**Use when**: 3–7 primary destinations on medium screens (tablets).
|
||||||
|
**Position**: Start edge (left in LTR), always visible.
|
||||||
|
|
||||||
|
### Anatomy
|
||||||
|
- Width: 80dp
|
||||||
|
- Optional FAB at top
|
||||||
|
- Navigation items vertically stacked
|
||||||
|
- Active item shows indicator pill
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
```html
|
||||||
|
<nav class="md3-nav-rail" aria-label="Main navigation">
|
||||||
|
<!-- Optional FAB -->
|
||||||
|
<md-fab size="small" variant="tertiary" aria-label="Compose">
|
||||||
|
<md-icon slot="icon">edit</md-icon>
|
||||||
|
</md-fab>
|
||||||
|
|
||||||
|
<div class="md3-nav-rail__items" role="tablist">
|
||||||
|
<a href="/" class="md3-nav-rail__item" role="tab" aria-selected="true" aria-current="page">
|
||||||
|
<div class="md3-nav-rail__indicator">
|
||||||
|
<md-icon>home</md-icon>
|
||||||
|
</div>
|
||||||
|
<span class="md3-nav-rail__label">Home</span>
|
||||||
|
</a>
|
||||||
|
<a href="/search" class="md3-nav-rail__item" role="tab" aria-selected="false">
|
||||||
|
<div class="md3-nav-rail__indicator">
|
||||||
|
<md-icon>search</md-icon>
|
||||||
|
</div>
|
||||||
|
<span class="md3-nav-rail__label">Search</span>
|
||||||
|
</a>
|
||||||
|
<a href="/settings" class="md3-nav-rail__item" role="tab" aria-selected="false">
|
||||||
|
<div class="md3-nav-rail__indicator">
|
||||||
|
<md-icon>settings</md-icon>
|
||||||
|
</div>
|
||||||
|
<span class="md3-nav-rail__label">Settings</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-nav-rail {
|
||||||
|
width: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
border-right: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-nav-rail__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-nav-rail__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font: var(--md-sys-typescale-label-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-nav-rail__indicator {
|
||||||
|
width: 56px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--md-sys-shape-corner-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-nav-rail__item[aria-selected="true"] .md3-nav-rail__indicator {
|
||||||
|
background: var(--md-sys-color-secondary-container);
|
||||||
|
color: var(--md-sys-color-on-secondary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-nav-rail__item[aria-selected="true"] {
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Guidelines
|
||||||
|
- Align items to top (below optional FAB)
|
||||||
|
- Show labels always (optional to hide, but recommended to show)
|
||||||
|
- FAB at top is optional but common
|
||||||
|
- Elevation level 0
|
||||||
|
|
||||||
|
## Navigation Drawer
|
||||||
|
|
||||||
|
**Use when**: Many destinations, expanded screens, or deep hierarchies.
|
||||||
|
**Position**: Start edge, standard (persistent) or modal (overlay).
|
||||||
|
|
||||||
|
### Standard Drawer (Persistent)
|
||||||
|
|
||||||
|
Always visible alongside content. Width: 360dp.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-layout">
|
||||||
|
<md-navigation-drawer opened>
|
||||||
|
<div slot="headline">App Name</div>
|
||||||
|
<md-list>
|
||||||
|
<md-list-item type="button" active>
|
||||||
|
<md-icon slot="start">inbox</md-icon>
|
||||||
|
<div slot="headline">Inbox</div>
|
||||||
|
<div slot="trailing-supporting-text">24</div>
|
||||||
|
</md-list-item>
|
||||||
|
<md-list-item type="button">
|
||||||
|
<md-icon slot="start">send</md-icon>
|
||||||
|
<div slot="headline">Sent</div>
|
||||||
|
</md-list-item>
|
||||||
|
<md-divider></md-divider>
|
||||||
|
<md-list-item type="button">
|
||||||
|
<md-icon slot="start">drafts</md-icon>
|
||||||
|
<div slot="headline">Drafts</div>
|
||||||
|
</md-list-item>
|
||||||
|
</md-list>
|
||||||
|
</md-navigation-drawer>
|
||||||
|
<main class="md3-content">
|
||||||
|
<!-- Page content -->
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal Drawer (Overlay)
|
||||||
|
|
||||||
|
Overlays content with a scrim. Used on smaller screens or when content space is limited.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-navigation-drawer type="modal" id="nav-drawer">
|
||||||
|
<!-- Same content as standard -->
|
||||||
|
</md-navigation-drawer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toggle drawer
|
||||||
|
document.getElementById('menu-btn').addEventListener('click', () => {
|
||||||
|
const drawer = document.getElementById('nav-drawer');
|
||||||
|
drawer.opened = !drawer.opened;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Guidelines
|
||||||
|
- Standard drawer uses `surface-container` background
|
||||||
|
- Modal drawer has elevation level 1 and scrim overlay
|
||||||
|
- Group destinations with dividers and section headers
|
||||||
|
- Active item uses `secondary-container` background
|
||||||
|
- Shape: `large` on end corners (right edge in LTR)
|
||||||
|
|
||||||
|
## Top App Bar
|
||||||
|
|
||||||
|
**Use when**: Every screen needs a title and optional actions.
|
||||||
|
|
||||||
|
**I/O 2026 note:** Search app bars are part of the current expressive search guidance. In Compose, check your Material3 BOM for expressive app bar and search APIs before hand-rolling. For web, combine a token-backed top app bar with a custom search field/view because Material Web does not expose full expressive search parity.
|
||||||
|
|
||||||
|
### Variants
|
||||||
|
|
||||||
|
| Variant | Title | Height | Scroll Behavior |
|
||||||
|
|---------|-------|--------|----------------|
|
||||||
|
| Center-aligned | Center | 64dp | Elevates to level 2 on scroll |
|
||||||
|
| Small | Start-aligned | 64dp | Elevates to level 2 on scroll |
|
||||||
|
| Medium | Bottom, start-aligned | 112dp | Collapses to 64dp on scroll |
|
||||||
|
| Large | Bottom, start-aligned | 152dp | Collapses to 64dp on scroll |
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Small app bar -->
|
||||||
|
<header class="md3-top-app-bar">
|
||||||
|
<md-icon-button aria-label="Open menu">
|
||||||
|
<md-icon>menu</md-icon>
|
||||||
|
</md-icon-button>
|
||||||
|
<h1 class="md3-top-app-bar__title">Page Title</h1>
|
||||||
|
<md-icon-button aria-label="Search">
|
||||||
|
<md-icon>search</md-icon>
|
||||||
|
</md-icon-button>
|
||||||
|
<md-icon-button aria-label="More options">
|
||||||
|
<md-icon>more_vert</md-icon>
|
||||||
|
</md-icon-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Medium app bar (collapsed state shown; expand on scroll-to-top) -->
|
||||||
|
<header class="md3-top-app-bar md3-top-app-bar--medium">
|
||||||
|
<div class="md3-top-app-bar__row">
|
||||||
|
<md-icon-button aria-label="Back"><md-icon>arrow_back</md-icon></md-icon-button>
|
||||||
|
<span class="md3-top-app-bar__title-collapsed"></span>
|
||||||
|
<md-icon-button aria-label="More"><md-icon>more_vert</md-icon></md-icon-button>
|
||||||
|
</div>
|
||||||
|
<div class="md3-top-app-bar__expanded-title">
|
||||||
|
<h1>Page Title</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-top-app-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-top-app-bar__title {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 12px;
|
||||||
|
font: var(--md-sys-typescale-title-large);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrolled state: elevate */
|
||||||
|
.md3-top-app-bar--scrolled {
|
||||||
|
background: var(--md-sys-color-surface-container);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scroll Behavior
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Elevate app bar on scroll
|
||||||
|
const appBar = document.querySelector('.md3-top-app-bar');
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
appBar.classList.toggle('md3-top-app-bar--scrolled', window.scrollY > 0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
**Use when**: Switching between related content at the same hierarchy level.
|
||||||
|
|
||||||
|
### Primary vs Secondary
|
||||||
|
|
||||||
|
- **Primary tabs**: Top-level content switching (Flights / Hotels / Explore)
|
||||||
|
- **Secondary tabs**: Sub-sections within primary content
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Primary tabs -->
|
||||||
|
<md-tabs>
|
||||||
|
<md-primary-tab active>
|
||||||
|
<md-icon slot="icon">flight</md-icon>
|
||||||
|
Flights
|
||||||
|
</md-primary-tab>
|
||||||
|
<md-primary-tab>Hotels</md-primary-tab>
|
||||||
|
<md-primary-tab>Car Rental</md-primary-tab>
|
||||||
|
</md-tabs>
|
||||||
|
|
||||||
|
<!-- Secondary tabs (nested under primary) -->
|
||||||
|
<md-tabs>
|
||||||
|
<md-secondary-tab active>Overview</md-secondary-tab>
|
||||||
|
<md-secondary-tab>Reviews</md-secondary-tab>
|
||||||
|
<md-secondary-tab>Photos</md-secondary-tab>
|
||||||
|
</md-tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tab + Panel Connection
|
||||||
|
|
||||||
|
```html
|
||||||
|
<md-tabs id="my-tabs">
|
||||||
|
<md-primary-tab id="tab-1" aria-controls="panel-1" active>Tab 1</md-primary-tab>
|
||||||
|
<md-primary-tab id="tab-2" aria-controls="panel-2">Tab 2</md-primary-tab>
|
||||||
|
</md-tabs>
|
||||||
|
|
||||||
|
<div id="panel-1" role="tabpanel" aria-labelledby="tab-1">
|
||||||
|
Panel 1 content
|
||||||
|
</div>
|
||||||
|
<div id="panel-2" role="tabpanel" aria-labelledby="tab-2" hidden>
|
||||||
|
Panel 2 content
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('my-tabs').addEventListener('change', (e) => {
|
||||||
|
// Hide all panels
|
||||||
|
document.querySelectorAll('[role="tabpanel"]').forEach(p => p.hidden = true);
|
||||||
|
// Show selected panel
|
||||||
|
const activeTab = e.target.querySelector('[active]');
|
||||||
|
const panelId = activeTab.getAttribute('aria-controls');
|
||||||
|
document.getElementById(panelId).hidden = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Navigation Pattern
|
||||||
|
|
||||||
|
The key MD3 pattern: navigation component transforms across breakpoints.
|
||||||
|
|
||||||
|
### Mobile → Tablet → Desktop
|
||||||
|
|
||||||
|
```
|
||||||
|
Compact (<600dp): Navigation Bar (bottom)
|
||||||
|
Medium (600–839dp): Navigation Rail (side)
|
||||||
|
Expanded (840dp+): Navigation Drawer (side, standard)
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Implementation
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Hide all nav variants by default */
|
||||||
|
.md3-nav-bar,
|
||||||
|
.md3-nav-rail,
|
||||||
|
.md3-nav-drawer { display: none; }
|
||||||
|
|
||||||
|
/* Compact: show bottom navigation bar */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.md3-nav-bar { display: flex; }
|
||||||
|
.md3-app { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Medium: show navigation rail */
|
||||||
|
@media (min-width: 600px) and (max-width: 839px) {
|
||||||
|
.md3-nav-rail { display: flex; }
|
||||||
|
.md3-app { flex-direction: row; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded+: show navigation drawer */
|
||||||
|
@media (min-width: 840px) {
|
||||||
|
.md3-nav-drawer { display: flex; }
|
||||||
|
.md3-app { flex-direction: row; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Responsive Shell
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md3-app">
|
||||||
|
<!-- Navigation drawer (expanded+) -->
|
||||||
|
<aside class="md3-nav-drawer">
|
||||||
|
<md-navigation-drawer opened>
|
||||||
|
<div slot="headline">My App</div>
|
||||||
|
<md-list>
|
||||||
|
<md-list-item type="button" active>
|
||||||
|
<md-icon slot="start">home</md-icon>Home
|
||||||
|
</md-list-item>
|
||||||
|
<md-list-item type="button">
|
||||||
|
<md-icon slot="start">search</md-icon>Search
|
||||||
|
</md-list-item>
|
||||||
|
<md-list-item type="button">
|
||||||
|
<md-icon slot="start">settings</md-icon>Settings
|
||||||
|
</md-list-item>
|
||||||
|
</md-list>
|
||||||
|
</md-navigation-drawer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Navigation rail (medium) -->
|
||||||
|
<nav class="md3-nav-rail" aria-label="Main">
|
||||||
|
<md-fab size="small" aria-label="New"><md-icon slot="icon">add</md-icon></md-fab>
|
||||||
|
<a class="md3-nav-rail__item active"><md-icon>home</md-icon><span>Home</span></a>
|
||||||
|
<a class="md3-nav-rail__item"><md-icon>search</md-icon><span>Search</span></a>
|
||||||
|
<a class="md3-nav-rail__item"><md-icon>settings</md-icon><span>Settings</span></a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="md3-main">
|
||||||
|
<header class="md3-top-app-bar">
|
||||||
|
<md-icon-button class="md3-menu-btn" aria-label="Menu"><md-icon>menu</md-icon></md-icon-button>
|
||||||
|
<h1 class="md3-top-app-bar__title">Home</h1>
|
||||||
|
</header>
|
||||||
|
<div class="md3-body">
|
||||||
|
<!-- Page content -->
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Navigation bar (compact) -->
|
||||||
|
<md-navigation-bar class="md3-nav-bar">
|
||||||
|
<md-navigation-tab label="Home" active>
|
||||||
|
<md-icon slot="active-icon">home</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">home</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
<md-navigation-tab label="Search">
|
||||||
|
<md-icon slot="active-icon">search</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">search</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
<md-navigation-tab label="Settings">
|
||||||
|
<md-icon slot="active-icon">settings</md-icon>
|
||||||
|
<md-icon slot="inactive-icon">settings</md-icon>
|
||||||
|
</md-navigation-tab>
|
||||||
|
</md-navigation-bar>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md3-app {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md3-main { flex: 1; display: flex; flex-direction: column; }
|
||||||
|
.md3-body { flex: 1; padding: 16px; }
|
||||||
|
|
||||||
|
/* Compact */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.md3-app { flex-direction: column; }
|
||||||
|
.md3-nav-rail, .md3-nav-drawer { display: none; }
|
||||||
|
.md3-nav-bar { display: flex; order: 1; }
|
||||||
|
.md3-menu-btn { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Medium */
|
||||||
|
@media (min-width: 600px) and (max-width: 839px) {
|
||||||
|
.md3-nav-bar, .md3-nav-drawer { display: none; }
|
||||||
|
.md3-nav-rail { display: flex; }
|
||||||
|
.md3-menu-btn { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded+ */
|
||||||
|
@media (min-width: 840px) {
|
||||||
|
.md3-nav-bar, .md3-nav-rail { display: none; }
|
||||||
|
.md3-nav-drawer { display: flex; }
|
||||||
|
.md3-menu-btn { display: none; }
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
# MD3 Theming and Dynamic Color
|
||||||
|
|
||||||
|
Complete guide to creating, applying, and managing Material Design 3 themes.
|
||||||
|
|
||||||
|
## Theme Architecture
|
||||||
|
|
||||||
|
The same **semantic roles** (primary, onSurface, surface containers, etc.) appear on every platform:
|
||||||
|
|
||||||
|
| Platform | Theme surface |
|
||||||
|
|----------|----------------|
|
||||||
|
| **Jetpack Compose** | `MaterialTheme(colorScheme, typography, shapes, …)` |
|
||||||
|
| **Flutter** | `ThemeData` + `ColorScheme`, `useMaterial3: true` |
|
||||||
|
| **Web** | CSS custom properties `--md-sys-*` on `:root` or a subtree |
|
||||||
|
|
||||||
|
**Web:** [Material Web is maintenance-only; M3 Expressive is not implemented on Web](https://m3.material.io/develop/web). Use `@material/web` + CSS knowing the stack is limited.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Jetpack Compose Theming
|
||||||
|
|
||||||
|
Use `androidx.compose.material3.MaterialTheme`. Prefer **dynamic color** on **Android 12+ (API 31+)** when enabled; otherwise use `lightColorScheme` / `darkColorScheme` or Kotlin generated by [Material Theme Builder](https://material-foundation.github.io/material-theme-builder/).
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MyAppTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
darkTheme -> darkColorScheme(
|
||||||
|
primary = Color(0xFFD0BCFF),
|
||||||
|
onPrimary = Color(0xFF381E72),
|
||||||
|
// … add remaining roles or use generated Theme.kt
|
||||||
|
)
|
||||||
|
else -> lightColorScheme(
|
||||||
|
primary = Color(0xFF6750A4),
|
||||||
|
onPrimary = Color(0xFFFFFFFF),
|
||||||
|
// … add remaining roles or use generated Theme.kt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography(),
|
||||||
|
shapes = Shapes(),
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flutter Theming
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
// Basic MD3 theme from seed
|
||||||
|
MaterialApp(
|
||||||
|
theme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.deepPurple,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
darkTheme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.deepPurple,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dynamic color (Android 12+)
|
||||||
|
// Requires package:dynamic_color
|
||||||
|
DynamicColorBuilder(
|
||||||
|
builder: (lightDynamic, darkDynamic) {
|
||||||
|
return MaterialApp(
|
||||||
|
theme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||||
|
),
|
||||||
|
darkTheme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: darkDynamic ?? ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.deepPurple,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web: Theme Builder and CSS
|
||||||
|
|
||||||
|
### 1. Choose a Seed Color
|
||||||
|
Start with a single hex color that represents your brand. The entire scheme generates from this seed.
|
||||||
|
|
||||||
|
### 2. Generate the Scheme
|
||||||
|
Use `@material/material-color-utilities` to generate light and dark schemes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @material/material-color-utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import {
|
||||||
|
argbFromHex,
|
||||||
|
hexFromArgb,
|
||||||
|
SchemeContent,
|
||||||
|
Hct,
|
||||||
|
} from '@material/material-color-utilities';
|
||||||
|
|
||||||
|
function generateTheme(seedHex, isDark = false, contrast = 0.0) {
|
||||||
|
const hct = Hct.fromInt(argbFromHex(seedHex));
|
||||||
|
const scheme = new SchemeContent(hct, isDark, contrast);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Primary
|
||||||
|
'--md-sys-color-primary': hexFromArgb(scheme.primary),
|
||||||
|
'--md-sys-color-on-primary': hexFromArgb(scheme.onPrimary),
|
||||||
|
'--md-sys-color-primary-container': hexFromArgb(scheme.primaryContainer),
|
||||||
|
'--md-sys-color-on-primary-container': hexFromArgb(scheme.onPrimaryContainer),
|
||||||
|
// Secondary
|
||||||
|
'--md-sys-color-secondary': hexFromArgb(scheme.secondary),
|
||||||
|
'--md-sys-color-on-secondary': hexFromArgb(scheme.onSecondary),
|
||||||
|
'--md-sys-color-secondary-container': hexFromArgb(scheme.secondaryContainer),
|
||||||
|
'--md-sys-color-on-secondary-container': hexFromArgb(scheme.onSecondaryContainer),
|
||||||
|
// Tertiary
|
||||||
|
'--md-sys-color-tertiary': hexFromArgb(scheme.tertiary),
|
||||||
|
'--md-sys-color-on-tertiary': hexFromArgb(scheme.onTertiary),
|
||||||
|
'--md-sys-color-tertiary-container': hexFromArgb(scheme.tertiaryContainer),
|
||||||
|
'--md-sys-color-on-tertiary-container': hexFromArgb(scheme.onTertiaryContainer),
|
||||||
|
// Error
|
||||||
|
'--md-sys-color-error': hexFromArgb(scheme.error),
|
||||||
|
'--md-sys-color-on-error': hexFromArgb(scheme.onError),
|
||||||
|
'--md-sys-color-error-container': hexFromArgb(scheme.errorContainer),
|
||||||
|
'--md-sys-color-on-error-container': hexFromArgb(scheme.onErrorContainer),
|
||||||
|
// Surface
|
||||||
|
'--md-sys-color-surface': hexFromArgb(scheme.surface),
|
||||||
|
'--md-sys-color-on-surface': hexFromArgb(scheme.onSurface),
|
||||||
|
'--md-sys-color-on-surface-variant': hexFromArgb(scheme.onSurfaceVariant),
|
||||||
|
'--md-sys-color-surface-container-lowest': hexFromArgb(scheme.surfaceContainerLowest),
|
||||||
|
'--md-sys-color-surface-container-low': hexFromArgb(scheme.surfaceContainerLow),
|
||||||
|
'--md-sys-color-surface-container': hexFromArgb(scheme.surfaceContainer),
|
||||||
|
'--md-sys-color-surface-container-high': hexFromArgb(scheme.surfaceContainerHigh),
|
||||||
|
'--md-sys-color-surface-container-highest': hexFromArgb(scheme.surfaceContainerHighest),
|
||||||
|
'--md-sys-color-surface-dim': hexFromArgb(scheme.surfaceDim),
|
||||||
|
'--md-sys-color-surface-bright': hexFromArgb(scheme.surfaceBright),
|
||||||
|
// Outline
|
||||||
|
'--md-sys-color-outline': hexFromArgb(scheme.outline),
|
||||||
|
'--md-sys-color-outline-variant': hexFromArgb(scheme.outlineVariant),
|
||||||
|
// Inverse
|
||||||
|
'--md-sys-color-inverse-surface': hexFromArgb(scheme.inverseSurface),
|
||||||
|
'--md-sys-color-inverse-on-surface': hexFromArgb(scheme.inverseOnSurface),
|
||||||
|
'--md-sys-color-inverse-primary': hexFromArgb(scheme.inversePrimary),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Apply the Theme
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function applyTheme(seedHex, isDark = false, contrast = 0.0) {
|
||||||
|
const tokens = generateTheme(seedHex, isDark, contrast);
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [prop, value] of Object.entries(tokens)) {
|
||||||
|
root.style.setProperty(prop, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply light theme
|
||||||
|
applyTheme('#1A73E8');
|
||||||
|
|
||||||
|
// Apply dark theme
|
||||||
|
applyTheme('#1A73E8', true);
|
||||||
|
|
||||||
|
// Apply high contrast
|
||||||
|
applyTheme('#1A73E8', false, 1.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Export as CSS
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function exportAsCSS(seedHex) {
|
||||||
|
const light = generateTheme(seedHex, false);
|
||||||
|
const dark = generateTheme(seedHex, true);
|
||||||
|
|
||||||
|
let css = ':root {\n';
|
||||||
|
for (const [prop, value] of Object.entries(light)) {
|
||||||
|
css += ` ${prop}: ${value};\n`;
|
||||||
|
}
|
||||||
|
css += '}\n\n';
|
||||||
|
css += '@media (prefers-color-scheme: dark) {\n :root {\n';
|
||||||
|
for (const [prop, value] of Object.entries(dark)) {
|
||||||
|
css += ` ${prop}: ${value};\n`;
|
||||||
|
}
|
||||||
|
css += ' }\n}\n';
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brand Color Integration
|
||||||
|
|
||||||
|
### Mapping Existing Brand Colors
|
||||||
|
|
||||||
|
If you have existing brand colors, map them to MD3 roles:
|
||||||
|
|
||||||
|
| Brand concept | MD3 role |
|
||||||
|
|--------------|----------|
|
||||||
|
| Primary brand color | Use as seed for `primary` palette |
|
||||||
|
| Secondary brand color | Override `secondary` or use as custom color |
|
||||||
|
| Accent color | Map to `tertiary` |
|
||||||
|
| Alert/danger color | Override `error` (or keep MD3 default) |
|
||||||
|
| Background | Generated from seed (don't hardcode) |
|
||||||
|
|
||||||
|
### Using a Brand Color as Seed
|
||||||
|
|
||||||
|
The simplest approach: use your primary brand color as the seed. The algorithm will generate harmonious secondary and tertiary colors automatically.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Your brand's primary blue
|
||||||
|
applyTheme('#1A73E8');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color Harmonization for Additional Brand Colors
|
||||||
|
|
||||||
|
If you need to integrate a specific brand color that wasn't generated from the seed:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { Blend, argbFromHex, hexFromArgb } from '@material/material-color-utilities';
|
||||||
|
|
||||||
|
// Your brand's orange accent color
|
||||||
|
const brandOrange = argbFromHex('#FF6D00');
|
||||||
|
// The generated primary color
|
||||||
|
const schemePrimary = argbFromHex('#1A73E8');
|
||||||
|
|
||||||
|
// Harmonize: shifts the hue slightly toward primary
|
||||||
|
const harmonizedOrange = hexFromArgb(Blend.harmonize(brandOrange, schemePrimary));
|
||||||
|
// Use harmonizedOrange as a custom color in your theme
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dark Theme
|
||||||
|
|
||||||
|
### Automatic Generation
|
||||||
|
|
||||||
|
Dark theme is automatically generated from the same seed color. The tonal mapping simply shifts:
|
||||||
|
- Light theme uses lighter tones (80-100) for surfaces, darker tones (10-40) for accents
|
||||||
|
- Dark theme inverts: darker tones (4-22) for surfaces, lighter tones (80-90) for accents
|
||||||
|
|
||||||
|
### CSS Implementation
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Light theme (default) */
|
||||||
|
:root {
|
||||||
|
--md-sys-color-primary: #6750A4;
|
||||||
|
--md-sys-color-surface: #FEF7FF;
|
||||||
|
/* ... all light tokens ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme via media query */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--md-sys-color-primary: #D0BCFF;
|
||||||
|
--md-sys-color-surface: #141218;
|
||||||
|
/* ... all dark tokens ... */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme via class (for manual toggle) */
|
||||||
|
.dark-theme {
|
||||||
|
--md-sys-color-primary: #D0BCFF;
|
||||||
|
--md-sys-color-surface: #141218;
|
||||||
|
/* ... all dark tokens ... */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Theme Switching
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class ThemeManager {
|
||||||
|
constructor(seedHex) {
|
||||||
|
this.seedHex = seedHex;
|
||||||
|
this.isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
this.contrast = 0.0; // standard
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||||
|
this.isDark = e.matches;
|
||||||
|
this.apply();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
apply() {
|
||||||
|
const tokens = generateTheme(this.seedHex, this.isDark, this.contrast);
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [prop, value] of Object.entries(tokens)) {
|
||||||
|
root.style.setProperty(prop, value);
|
||||||
|
}
|
||||||
|
root.setAttribute('data-theme', this.isDark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleDark() {
|
||||||
|
this.isDark = !this.isDark;
|
||||||
|
this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
setContrast(level) {
|
||||||
|
// 0.0 = standard, 0.5 = medium, 1.0 = high
|
||||||
|
this.contrast = level;
|
||||||
|
this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSeed(hex) {
|
||||||
|
this.seedHex = hex;
|
||||||
|
this.apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const theme = new ThemeManager('#6750A4');
|
||||||
|
theme.apply();
|
||||||
|
|
||||||
|
// Toggle dark mode
|
||||||
|
document.getElementById('theme-toggle').addEventListener('click', () => theme.toggleDark());
|
||||||
|
```
|
||||||
|
|
||||||
|
## High Contrast Themes
|
||||||
|
|
||||||
|
MD3 supports 3 contrast levels, adjustable via the `contrast` parameter:
|
||||||
|
|
||||||
|
| Level | Value | Effect |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| Standard | 0.0 | Default tonal distance |
|
||||||
|
| Medium | 0.5 | Increased tonal distance, easier to read |
|
||||||
|
| High | 1.0 | Maximum tonal distance, highest legibility |
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Standard contrast
|
||||||
|
const standard = new SchemeContent(hct, isDark, 0.0);
|
||||||
|
|
||||||
|
// Medium contrast
|
||||||
|
const medium = new SchemeContent(hct, isDark, 0.5);
|
||||||
|
|
||||||
|
// High contrast
|
||||||
|
const high = new SchemeContent(hct, isDark, 1.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Higher contrast increases the tonal distance between paired color roles (e.g., `primary` and `on-primary`), making text more legible without fundamentally changing the color feel.
|
||||||
|
|
||||||
|
## Component-Level Overrides (web)
|
||||||
|
|
||||||
|
Override individual **web** component appearance using component-specific CSS custom properties. **Compose** uses `TextFieldDefaults`, `ButtonDefaults`, `MaterialTheme`, etc.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Override specific button colors */
|
||||||
|
md-filled-button {
|
||||||
|
--md-filled-button-container-color: var(--md-sys-color-tertiary);
|
||||||
|
--md-filled-button-label-text-color: var(--md-sys-color-on-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override text field shape */
|
||||||
|
md-outlined-text-field {
|
||||||
|
--md-outlined-text-field-container-shape: var(--md-sys-shape-corner-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override FAB */
|
||||||
|
md-fab {
|
||||||
|
--md-fab-container-color: var(--md-sys-color-tertiary-container);
|
||||||
|
--md-fab-icon-color: var(--md-sys-color-on-tertiary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override switch colors */
|
||||||
|
md-switch {
|
||||||
|
--md-switch-selected-track-color: var(--md-sys-color-primary);
|
||||||
|
--md-switch-selected-handle-color: var(--md-sys-color-on-primary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Token Naming Pattern
|
||||||
|
|
||||||
|
Component tokens follow: `--md-{component}-{element}-{property}`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `--md-filled-button-container-color`
|
||||||
|
- `--md-filled-button-container-shape`
|
||||||
|
- `--md-filled-button-label-text-color`
|
||||||
|
- `--md-outlined-text-field-outline-color`
|
||||||
|
- `--md-outlined-text-field-focus-outline-color`
|
||||||
|
- `--md-fab-container-color`
|
||||||
|
- `--md-fab-container-shape`
|
||||||
|
- `--md-switch-selected-track-color`
|
||||||
|
|
||||||
|
## Dynamic Color from Content
|
||||||
|
|
||||||
|
Extract a color from an image and apply it as the theme:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import {
|
||||||
|
QuantizerCelebi,
|
||||||
|
Score,
|
||||||
|
argbFromRgb,
|
||||||
|
} from '@material/material-color-utilities';
|
||||||
|
|
||||||
|
async function themeFromImage(imageUrl) {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.src = imageUrl;
|
||||||
|
await img.decode();
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const pixels = [];
|
||||||
|
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||||
|
pixels.push(argbFromRgb(imageData.data[i], imageData.data[i+1], imageData.data[i+2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantize and score to find best seed color
|
||||||
|
const quantized = QuantizerCelebi.quantize(pixels, 128);
|
||||||
|
const scored = Score.score(quantized);
|
||||||
|
const seedColor = scored[0]; // Best color
|
||||||
|
|
||||||
|
return hexFromArgb(seedColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const seed = await themeFromImage('/album-cover.jpg');
|
||||||
|
applyTheme(seed);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scoped Themes
|
||||||
|
|
||||||
|
Apply different themes to different sections of the UI:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Default theme */
|
||||||
|
:root {
|
||||||
|
--md-sys-color-primary: #6750A4;
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scoped theme for a section */
|
||||||
|
.premium-section {
|
||||||
|
--md-sys-color-primary: #B69DF8;
|
||||||
|
--md-sys-color-primary-container: #3F2D7A;
|
||||||
|
/* Only override what changes */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<main>
|
||||||
|
<!-- Uses default theme -->
|
||||||
|
<section class="regular">
|
||||||
|
<md-filled-button>Standard</md-filled-button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Uses premium theme -->
|
||||||
|
<section class="premium-section">
|
||||||
|
<md-filled-button>Premium</md-filled-button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
|
CSS custom properties cascade, so child elements automatically inherit the closest ancestor's tokens.
|
||||||
400
.agents/skills/material-3/references/typography-and-shape.md
Normal file
400
.agents/skills/material-3/references/typography-and-shape.md
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
# MD3 Typography, Shape, Elevation, and Motion
|
||||||
|
|
||||||
|
Reference for Material Design 3's visual token systems beyond color.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
### Type Scale
|
||||||
|
|
||||||
|
MD3 uses 15 baseline styles + 15 emphasized styles organized in 5 categories (Display, Headline, Title, Body, Label) with 3 sizes each (Large, Medium, Small).
|
||||||
|
|
||||||
|
#### Baseline Type Scale (Default Values)
|
||||||
|
|
||||||
|
| Style | Font | Weight | Size (sp) | Size (rem) | Line Height | Tracking |
|
||||||
|
|-------|------|--------|-----------|------------|-------------|----------|
|
||||||
|
| Display Large | Roboto | 400 | 57 | 3.5625 | 64sp / 4rem | -0.25px |
|
||||||
|
| Display Medium | Roboto | 400 | 45 | 2.8125 | 52sp / 3.25rem | 0 |
|
||||||
|
| Display Small | Roboto | 400 | 36 | 2.25 | 44sp / 2.75rem | 0 |
|
||||||
|
| Headline Large | Roboto | 400 | 32 | 2 | 40sp / 2.5rem | 0 |
|
||||||
|
| Headline Medium | Roboto | 400 | 28 | 1.75 | 36sp / 2.25rem | 0 |
|
||||||
|
| Headline Small | Roboto | 400 | 24 | 1.5 | 32sp / 2rem | 0 |
|
||||||
|
| Title Large | Roboto | 400 | 22 | 1.375 | 28sp / 1.75rem | 0 |
|
||||||
|
| Title Medium | Roboto | 500 | 16 | 1 | 24sp / 1.5rem | 0.15px |
|
||||||
|
| Title Small | Roboto | 500 | 14 | 0.875 | 20sp / 1.25rem | 0.1px |
|
||||||
|
| Body Large | Roboto | 400 | 16 | 1 | 24sp / 1.5rem | 0.5px |
|
||||||
|
| Body Medium | Roboto | 400 | 14 | 0.875 | 20sp / 1.25rem | 0.25px |
|
||||||
|
| Body Small | Roboto | 400 | 12 | 0.75 | 16sp / 1rem | 0.4px |
|
||||||
|
| Label Large | Roboto | 500 | 14 | 0.875 | 20sp / 1.25rem | 0.1px |
|
||||||
|
| Label Medium | Roboto | 500 | 12 | 0.75 | 16sp / 1rem | 0.5px |
|
||||||
|
| Label Small | Roboto | 500 | 11 | 0.6875 | 16sp / 1rem | 0.5px |
|
||||||
|
|
||||||
|
#### Emphasized Type Styles (Expressive Update)
|
||||||
|
|
||||||
|
The 15 emphasized styles mirror the baseline scale but with **higher weight** and minor adjustments. Use for:
|
||||||
|
- Selected/active states in components
|
||||||
|
- Primary action buttons
|
||||||
|
- Headlines needing emphasis
|
||||||
|
- Unread/important content
|
||||||
|
|
||||||
|
To use: swap the baseline token for the emphasized version:
|
||||||
|
- Baseline: `md.sys.typescale.display-large`
|
||||||
|
- Emphasized: `md.sys.typescale.emphasized.display-large`
|
||||||
|
|
||||||
|
### CSS Custom Properties
|
||||||
|
|
||||||
|
Each type style maps to individual axis tokens:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Example: Body Large */
|
||||||
|
--md-sys-typescale-body-large-font: 'Roboto', sans-serif;
|
||||||
|
--md-sys-typescale-body-large-weight: 400;
|
||||||
|
--md-sys-typescale-body-large-size: 1rem; /* 16sp */
|
||||||
|
--md-sys-typescale-body-large-line-height: 1.5rem; /* 24sp */
|
||||||
|
--md-sys-typescale-body-large-tracking: 0.03125rem; /* 0.5px */
|
||||||
|
|
||||||
|
/* Example: Title Medium */
|
||||||
|
--md-sys-typescale-title-medium-font: 'Roboto', sans-serif;
|
||||||
|
--md-sys-typescale-title-medium-weight: 500;
|
||||||
|
--md-sys-typescale-title-medium-size: 1rem;
|
||||||
|
--md-sys-typescale-title-medium-line-height: 1.5rem;
|
||||||
|
--md-sys-typescale-title-medium-tracking: 0.009375rem;
|
||||||
|
|
||||||
|
/* Example: Label Large (used for buttons) */
|
||||||
|
--md-sys-typescale-label-large-font: 'Roboto', sans-serif;
|
||||||
|
--md-sys-typescale-label-large-weight: 500;
|
||||||
|
--md-sys-typescale-label-large-size: 0.875rem;
|
||||||
|
--md-sys-typescale-label-large-line-height: 1.25rem;
|
||||||
|
--md-sys-typescale-label-large-tracking: 0.00625rem;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Type Styles in CSS
|
||||||
|
|
||||||
|
Apply using individual properties:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.headline {
|
||||||
|
font-family: var(--md-sys-typescale-headline-large-font);
|
||||||
|
font-weight: var(--md-sys-typescale-headline-large-weight);
|
||||||
|
font-size: var(--md-sys-typescale-headline-large-size);
|
||||||
|
line-height: var(--md-sys-typescale-headline-large-line-height);
|
||||||
|
letter-spacing: var(--md-sys-typescale-headline-large-tracking);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the shorthand font property for convenience (note: requires defining the shorthand token):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.headline {
|
||||||
|
font: var(--md-sys-typescale-headline-large-weight)
|
||||||
|
var(--md-sys-typescale-headline-large-size) /
|
||||||
|
var(--md-sys-typescale-headline-large-line-height)
|
||||||
|
var(--md-sys-typescale-headline-large-font);
|
||||||
|
letter-spacing: var(--md-sys-typescale-headline-large-tracking);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typeface Customization
|
||||||
|
|
||||||
|
MD3 uses two typeface roles:
|
||||||
|
- **Brand**: Used for Display and Headline styles (expression-focused)
|
||||||
|
- **Plain**: Used for Title, Body, and Label styles (readability-focused)
|
||||||
|
|
||||||
|
Both default to Roboto. To customize:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Brand typeface for display/headline */
|
||||||
|
--md-ref-typeface-brand: 'Your Display Font', sans-serif;
|
||||||
|
/* Plain typeface for body/label/title */
|
||||||
|
--md-ref-typeface-plain: 'Your Body Font', sans-serif;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Roboto Flex
|
||||||
|
|
||||||
|
Roboto Flex is a variable font supporting multiple axes:
|
||||||
|
- **Weight** (wght): 100–1000
|
||||||
|
- **Width** (wdth): 25–151
|
||||||
|
- **Optical size** (opsz): 8–144
|
||||||
|
|
||||||
|
```css
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto Flex';
|
||||||
|
src: url('RobotoFlex-VariableFont.woff2') format('woff2');
|
||||||
|
font-weight: 100 1000;
|
||||||
|
font-stretch: 25% 151%;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Font Size Units
|
||||||
|
|
||||||
|
| Platform | Unit | Conversion |
|
||||||
|
|----------|------|------------|
|
||||||
|
| Android | sp | 1:1 |
|
||||||
|
| Web | rem | sp / 16 = rem (assuming 16px root) |
|
||||||
|
|
||||||
|
Examples: 10sp = 0.625rem, 12sp = 0.75rem, 14sp = 0.875rem, 16sp = 1rem, 24sp = 1.5rem
|
||||||
|
|
||||||
|
### Component Type Usage
|
||||||
|
|
||||||
|
| Component | Type Style |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Button label | Label Large |
|
||||||
|
| Card title | Title Medium |
|
||||||
|
| Card body | Body Medium |
|
||||||
|
| Top app bar title | Title Large |
|
||||||
|
| Navigation label | Label Medium |
|
||||||
|
| Dialog headline | Headline Small |
|
||||||
|
| Dialog body | Body Medium |
|
||||||
|
| Chip label | Label Large |
|
||||||
|
| Text field input | Body Large |
|
||||||
|
| Text field label | Body Small (floating) / Body Large (resting) |
|
||||||
|
| List headline | Body Large |
|
||||||
|
| List supporting text | Body Medium |
|
||||||
|
| Snackbar text | Body Medium |
|
||||||
|
| Tooltip text | Body Small |
|
||||||
|
| Tab label | Title Small |
|
||||||
|
| Badge count | Label Small |
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
### Corner Radius Scale
|
||||||
|
|
||||||
|
| Token | Value (dp) | Value (px/CSS) | Default components |
|
||||||
|
|-------|-----------|----------------|-------------------|
|
||||||
|
| `none` | 0 | 0px | — |
|
||||||
|
| `extra-small` | 4 | 4px | Snackbar |
|
||||||
|
| `small` | 8 | 8px | Text fields, menus, chips |
|
||||||
|
| `medium` | 12 | 12px | Cards |
|
||||||
|
| `large` | 16 | 16px | FAB, extended FAB, nav drawer |
|
||||||
|
| `large-increased` | 20 | 20px | (Expressive update) |
|
||||||
|
| `extra-large` | 28 | 28px | Dialogs, bottom sheets, side sheets |
|
||||||
|
| `extra-large-increased` | 32 | 32px | (Expressive update) |
|
||||||
|
| `extra-extra-large` | 48 | 48px | (Expressive update) |
|
||||||
|
| `full` | — | 9999px | Buttons, badges, pills, sliders |
|
||||||
|
|
||||||
|
### CSS Custom Properties
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--md-sys-shape-corner-none: 0px;
|
||||||
|
--md-sys-shape-corner-extra-small: 4px;
|
||||||
|
--md-sys-shape-corner-small: 8px;
|
||||||
|
--md-sys-shape-corner-medium: 12px;
|
||||||
|
--md-sys-shape-corner-large: 16px;
|
||||||
|
--md-sys-shape-corner-large-increased: 20px;
|
||||||
|
--md-sys-shape-corner-extra-large: 28px;
|
||||||
|
--md-sys-shape-corner-extra-large-increased: 32px;
|
||||||
|
--md-sys-shape-corner-extra-extra-large: 48px;
|
||||||
|
--md-sys-shape-corner-full: 9999px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Shape Mapping
|
||||||
|
|
||||||
|
| Component | Default Shape Token |
|
||||||
|
|-----------|-------------------|
|
||||||
|
| Buttons (all types) | `full` |
|
||||||
|
| FAB | `large` |
|
||||||
|
| Extended FAB | `large` |
|
||||||
|
| Icon button | `full` |
|
||||||
|
| Chips | `small` |
|
||||||
|
| Cards | `medium` |
|
||||||
|
| Dialogs | `extra-large` |
|
||||||
|
| Text fields | `small` (top corners) |
|
||||||
|
| Menus | `small` |
|
||||||
|
| Navigation drawer | `large` (end corners) |
|
||||||
|
| Bottom sheets | `extra-large` (top corners) |
|
||||||
|
| Snackbar | `extra-small` |
|
||||||
|
| Badges | `full` |
|
||||||
|
| Sliders (handle) | `full` |
|
||||||
|
| Switch (track) | `full` |
|
||||||
|
| Tabs (indicator) | `full` (top corners) |
|
||||||
|
| Search bar | `full` |
|
||||||
|
|
||||||
|
### Shape Morphing (Expressive)
|
||||||
|
|
||||||
|
In the M3 Expressive update, components can morph between shapes on interaction:
|
||||||
|
- Button shapes morph when pressed
|
||||||
|
- Selected states can change shape
|
||||||
|
- Loading indicators use shape morphing to show progress
|
||||||
|
|
||||||
|
**Platform notes**: Shape morphing is **not** in `@material/web` ([Material Web is maintenance-only; Expressive not on Web](https://m3.material.io/develop/web)). **Jetpack Compose** is where Google documents expressive shape/motion behavior for Android. **Flutter:** check current Material 3 / expressive docs for your SDK. **Web:** approximate with CSS transitions on `border-radius`.
|
||||||
|
|
||||||
|
## Elevation
|
||||||
|
|
||||||
|
### Elevation Levels
|
||||||
|
|
||||||
|
| Level | DP Height | Use |
|
||||||
|
|-------|-----------|-----|
|
||||||
|
| 0 | 0dp | Most resting components |
|
||||||
|
| 1 | 1dp | Elevated variants (cards, sheets) |
|
||||||
|
| 2 | 3dp | Menus, nav bar, scrolled app bar |
|
||||||
|
| 3 | 6dp | FAB, dialogs, search, date/time pickers |
|
||||||
|
| 4 | 8dp | Hover/focus increase only |
|
||||||
|
| 5 | 12dp | Hover/focus increase only |
|
||||||
|
|
||||||
|
### Tonal Elevation (Not Shadows)
|
||||||
|
|
||||||
|
MD3 uses **tonal surface color** to communicate elevation, not shadows. Higher elevation = lighter surface tone (in light theme) or lighter surface tone (in dark theme).
|
||||||
|
|
||||||
|
The surface container roles map to this concept:
|
||||||
|
- Level 0: `surface` (flattest)
|
||||||
|
- Level 1: `surface-container-low`
|
||||||
|
- Level 2: `surface-container`
|
||||||
|
- Level 3: `surface-container-high`
|
||||||
|
- Level 4-5: `surface-container-highest`
|
||||||
|
|
||||||
|
### When to Use Shadows
|
||||||
|
|
||||||
|
Shadows are only appropriate when:
|
||||||
|
- A component floats over content that may be visually busy (e.g., FAB over images)
|
||||||
|
- Additional depth cue is needed beyond color (e.g., overlapping elements)
|
||||||
|
- The platform convention expects shadows (some Android components)
|
||||||
|
|
||||||
|
### CSS Shadow Values
|
||||||
|
|
||||||
|
When shadows are needed:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Level 1 */
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.3), 0 1px 3px 1px rgba(0,0,0,0.15);
|
||||||
|
|
||||||
|
/* Level 2 */
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.3), 0 2px 6px 2px rgba(0,0,0,0.15);
|
||||||
|
|
||||||
|
/* Level 3 */
|
||||||
|
box-shadow: 0 4px 8px 3px rgba(0,0,0,0.15), 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
|
||||||
|
/* Level 4 */
|
||||||
|
box-shadow: 0 6px 10px 4px rgba(0,0,0,0.15), 0 2px 3px rgba(0,0,0,0.3);
|
||||||
|
|
||||||
|
/* Level 5 */
|
||||||
|
box-shadow: 0 8px 12px 6px rgba(0,0,0,0.15), 0 4px 4px rgba(0,0,0,0.3);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Elevation Mapping
|
||||||
|
|
||||||
|
| Level | Components at Rest |
|
||||||
|
|-------|--------------------|
|
||||||
|
| 0 | App bar (flat), filled/tonal/outlined buttons, button groups, filled/outlined cards, carousel, chips, full-screen dialogs, icon buttons, lists, nav rail, segmented buttons, sliders, split buttons, tabs |
|
||||||
|
| 1 | Banners, modal bottom sheet, elevated button, elevated card, elevated chips, modal nav drawer, modal side sheet |
|
||||||
|
| 2 | App bar (scrolled), menus, nav bar, rich tooltips, toolbar |
|
||||||
|
| 3 | Date pickers, modal dialogs, extended FAB, FAB, FAB menu close button, search, time pickers |
|
||||||
|
|
||||||
|
**Hover/focus**: Most interactive components increase by 1 level on hover/focus (e.g., FAB goes from level 3 to level 4).
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
### Spring-Based Motion (Expressive Update)
|
||||||
|
|
||||||
|
MD3 Expressive (May 2025) introduced spring-based motion physics for component animations. Springs create more natural, responsive motion:
|
||||||
|
|
||||||
|
- Springs have no fixed duration — they respond dynamically to input
|
||||||
|
- Two schemes: **standard** (utilitarian) and **expressive** (bouncy)
|
||||||
|
- **Jetpack Compose** exposes motion schemes / spring-oriented APIs in current Material3 (see `MotionScheme` and your BOM). **MDC-Android** may differ by version. **Web:** Material Web does not implement Expressive motion physics — use easing/duration or custom CSS/JS. **Flutter:** check your Flutter/Material version for parity.
|
||||||
|
|
||||||
|
### Easing and Duration (Transitions)
|
||||||
|
|
||||||
|
The easing/duration system is used for **transitions** (entering, exiting, shared-axis) and as a web fallback:
|
||||||
|
|
||||||
|
#### Easing Sets
|
||||||
|
|
||||||
|
**Emphasized** (recommended for most transitions — captures the MD3 style):
|
||||||
|
| Type | CSS Cubic-bezier | Use |
|
||||||
|
|------|-----------------|-----|
|
||||||
|
| Emphasized | `cubic-bezier(0.2, 0, 0, 1)` | Begin and end on screen |
|
||||||
|
| Emphasized Decelerate | `cubic-bezier(0.05, 0.7, 0.1, 1)` | Enter the screen |
|
||||||
|
| Emphasized Accelerate | `cubic-bezier(0.3, 0, 0.8, 0.15)` | Exit the screen |
|
||||||
|
|
||||||
|
**Standard** (for utility transitions, web fallback):
|
||||||
|
| Type | CSS Cubic-bezier | Use |
|
||||||
|
|------|-----------------|-----|
|
||||||
|
| Standard | `cubic-bezier(0.2, 0, 0, 1)` | Begin and end on screen |
|
||||||
|
| Standard Decelerate | `cubic-bezier(0, 0, 0, 1)` | Enter the screen |
|
||||||
|
| Standard Accelerate | `cubic-bezier(0.3, 0, 1, 1)` | Exit the screen |
|
||||||
|
|
||||||
|
#### Duration Scale
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| Short 1 | 50ms | Micro-interactions |
|
||||||
|
| Short 2 | 100ms | Small transitions |
|
||||||
|
| Short 3 | 150ms | Small transitions |
|
||||||
|
| Short 4 | 200ms | Exit transitions |
|
||||||
|
| Medium 1 | 250ms | Medium transitions |
|
||||||
|
| Medium 2 | 300ms | Standard transitions |
|
||||||
|
| Medium 3 | 350ms | Medium transitions |
|
||||||
|
| Medium 4 | 400ms | Enter transitions |
|
||||||
|
| Long 1 | 450ms | Large transitions |
|
||||||
|
| Long 2 | 500ms | Large, emphasized transitions |
|
||||||
|
| Long 3 | 550ms | Complex transitions |
|
||||||
|
| Long 4 | 600ms | Complex transitions |
|
||||||
|
| Extra Long 1 | 700ms | Page transitions |
|
||||||
|
| Extra Long 2 | 800ms | Page transitions |
|
||||||
|
| Extra Long 3 | 900ms | Complex page transitions |
|
||||||
|
| Extra Long 4 | 1000ms | Complex page transitions |
|
||||||
|
|
||||||
|
#### Suggested Pairings
|
||||||
|
|
||||||
|
| Transition | Easing | Duration |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| Element stays on screen | Emphasized | 500ms |
|
||||||
|
| Element enters screen | Emphasized Decelerate | 400ms |
|
||||||
|
| Element exits permanently | Emphasized Accelerate | 200ms |
|
||||||
|
| Element exits temporarily | Emphasized | 300ms |
|
||||||
|
| Small utility transition | Standard | 300ms |
|
||||||
|
|
||||||
|
### CSS Implementation
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Easing */
|
||||||
|
--md-sys-motion-easing-emphasized: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
--md-sys-motion-easing-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1);
|
||||||
|
--md-sys-motion-easing-emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15);
|
||||||
|
--md-sys-motion-easing-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
--md-sys-motion-easing-standard-decelerate: cubic-bezier(0, 0, 0, 1);
|
||||||
|
--md-sys-motion-easing-standard-accelerate: cubic-bezier(0.3, 0, 1, 1);
|
||||||
|
|
||||||
|
/* Duration */
|
||||||
|
--md-sys-motion-duration-short1: 50ms;
|
||||||
|
--md-sys-motion-duration-short2: 100ms;
|
||||||
|
--md-sys-motion-duration-short3: 150ms;
|
||||||
|
--md-sys-motion-duration-short4: 200ms;
|
||||||
|
--md-sys-motion-duration-medium1: 250ms;
|
||||||
|
--md-sys-motion-duration-medium2: 300ms;
|
||||||
|
--md-sys-motion-duration-medium3: 350ms;
|
||||||
|
--md-sys-motion-duration-medium4: 400ms;
|
||||||
|
--md-sys-motion-duration-long1: 450ms;
|
||||||
|
--md-sys-motion-duration-long2: 500ms;
|
||||||
|
--md-sys-motion-duration-long3: 550ms;
|
||||||
|
--md-sys-motion-duration-long4: 600ms;
|
||||||
|
--md-sys-motion-duration-extra-long1: 700ms;
|
||||||
|
--md-sys-motion-duration-extra-long2: 800ms;
|
||||||
|
--md-sys-motion-duration-extra-long3: 900ms;
|
||||||
|
--md-sys-motion-duration-extra-long4: 1000ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Example: dialog enter */
|
||||||
|
.md3-dialog-enter {
|
||||||
|
animation: dialog-enter var(--md-sys-motion-duration-medium4)
|
||||||
|
var(--md-sys-motion-easing-emphasized-decelerate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-enter {
|
||||||
|
from { opacity: 0; transform: scale(0.8); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Example: fade out */
|
||||||
|
.md3-fade-out {
|
||||||
|
animation: fade-out var(--md-sys-motion-duration-short4)
|
||||||
|
var(--md-sys-motion-easing-emphasized-accelerate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -10,3 +10,4 @@ artifacts
|
|||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
android
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":1,"sessions":{"01a13c7f-2064-7712-a08f-c0512bba9197":{"updatedAt":1792083512470,"files":{"/Users/xtimms/Projects/psu-schedule/src/App.tsx":{"editCount":1,"findings":[],"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]}}},"517f765a-68b5-47d1-b966-31dd338440a6":{"updatedAt":1792177361129,"files":{"/Users/xtimms/Projects/psu-schedule/src/icons.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]},"/Users/xtimms/Projects/psu-schedule/shared/weeks.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/calendar.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/calendar.ts":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/source.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/sources.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/lessons.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/schedules.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/directory.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Search.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/test/directory.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Groups.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true}},"footerShown":true}}}
|
{"version":1,"sessions":{"01a13c7f-2064-7712-a08f-c0512bba9197":{"updatedAt":1792083512470,"files":{"/Users/xtimms/Projects/psu-schedule/src/App.tsx":{"editCount":1,"findings":[],"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]}}},"517f765a-68b5-47d1-b966-31dd338440a6":{"updatedAt":1792198645900,"files":{"/Users/xtimms/Projects/psu-schedule/src/icons.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]},"/Users/xtimms/Projects/psu-schedule/shared/weeks.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/calendar.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/calendar.ts":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/source.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/sources.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/lessons.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/schedules.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/directory.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Search.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/test/directory.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Groups.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/server/timetable.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}}},"footerShown":true}}}
|
||||||
25
README.md
25
README.md
@@ -96,6 +96,31 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
|||||||
|
|
||||||
Если HTTPS уже даёт ваш обратный прокси, профиль не нужен; приложение можно закрыть от внешней сети через `APP_BIND=127.0.0.1`. Контейнер включает curl и LibreOffice для `.doc`. Файл `docker-compose.yml` проверен `docker compose config`; сборка образа в этой проверке не запускалась.
|
Если HTTPS уже даёт ваш обратный прокси, профиль не нужен; приложение можно закрыть от внешней сети через `APP_BIND=127.0.0.1`. Контейнер включает curl и LibreOffice для `.doc`. Файл `docker-compose.yml` проверен `docker compose config`; сборка образа в этой проверке не запускалась.
|
||||||
|
|
||||||
|
## Android-приложение
|
||||||
|
|
||||||
|
В папке `android/` — нативный клиент на Kotlin и Jetpack Compose с компонентами Material 3 Expressive (`MaterialExpressiveTheme`, `ModalWideNavigationRail` и `WideNavigationRail`, соединённые `ToggleButton`, `LoadingIndicator`). Цвета совпадают с веб-версией. Разделы открываются из бокового меню (на планшете и в альбомной ориентации — постоянная боковая панель); карточки лежат на подложке с оттенком, системные панели следуют теме приложения.
|
||||||
|
|
||||||
|
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
|
||||||
|
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
|
||||||
|
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
|
||||||
|
- Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия.
|
||||||
|
- Группы (боковое меню): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы.
|
||||||
|
- Поиск по университету: преподаватель или аудитория на день, свободные аудитории на пару.
|
||||||
|
- Виджет «Сегодня» (Glance): текущая и следующая пара, обновляется каждые 15 минут без сети.
|
||||||
|
- Напоминания перед парой за 5–30 минут — локальные будильники из сохранённого расписания, без push-сервера. С разрешением «Будильники и напоминания» приходят точно в срок, без него — в окне ±5 минут.
|
||||||
|
- Офлайн: последнее расписание хранится на устройстве; каждые 6 часов WorkManager обновляет его, виджет и напоминания.
|
||||||
|
|
||||||
|
Даты считает сервер: `GET /api/timetable?doc=<id>&group=<группа>&from=ГГГГ-ММ-ДД&days=N` отдаёт дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. Поэтому правила белых и зелёных недель в Kotlin не дублируются.
|
||||||
|
|
||||||
|
Сборка (JDK 17, Android SDK 37):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd android
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Отладочная сборка ходит на `http://10.0.2.2:3010/` — это сервер из `docker compose` на компьютере, видимый из эмулятора (`APP_PORT=3010`). Другой адрес: `-PPSU_API_URL=…`. Релизная сборка принимает только HTTPS-адрес. Идентификатор приложения `ritm.schedule` — рабочий, перед публикацией его стоит заменить своим.
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
- `src/App.tsx` — интерфейс, каталог и импорт с предпросмотром.
|
- `src/App.tsx` — интерфейс, каталог и импорт с предпросмотром.
|
||||||
|
|||||||
7
android/.gitignore
vendored
Normal file
7
android/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.kotlin/
|
||||||
|
captures/
|
||||||
70
android/app/build.gradle.kts
Normal file
70
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
val apiUrl = providers.gradleProperty("PSU_API_URL")
|
||||||
|
.orElse(providers.environmentVariable("PSU_API_URL"))
|
||||||
|
.orElse("http://10.0.2.2:3010/")
|
||||||
|
.map { if (it.endsWith("/")) it else "$it/" }
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ritm.schedule"
|
||||||
|
compileSdk {
|
||||||
|
version = release(37) { minorApiLevel = 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "ritm.schedule"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
buildConfigField("String", "API_URL", "\"${apiUrl.get()}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
// A release build must talk to a public HTTPS server.
|
||||||
|
require(apiUrl.get().startsWith("https://") || !gradle.startParameter.taskNames.any { "Release" in it }) {
|
||||||
|
"Для релиза укажите HTTPS-адрес: -PPSU_API_URL=https://…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
implementation(libs.compose.ui)
|
||||||
|
implementation(libs.compose.material3)
|
||||||
|
implementation(libs.compose.icons)
|
||||||
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
implementation(libs.activity.compose)
|
||||||
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.core.ktx)
|
||||||
|
implementation(libs.coroutines.android)
|
||||||
|
implementation(libs.serialization.json)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.datastore.preferences)
|
||||||
|
implementation(libs.glance.appwidget)
|
||||||
|
implementation(libs.glance.material3)
|
||||||
|
implementation(libs.work.runtime)
|
||||||
|
coreLibraryDesugaring(libs.desugar)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
4
android/app/proguard-rules.pro
vendored
Normal file
4
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# kotlinx.serialization keeps generated serializers; models are plain data classes.
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-keep,includedescriptorclasses class ritm.schedule.data.**$$serializer { *; }
|
||||||
|
-keepclassmembers class ritm.schedule.data.** { *** Companion; }
|
||||||
55
android/app/src/main/AndroidManifest.xml
Normal file
55
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<!-- Optional: the student can allow exact reminder times in system settings. -->
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".ScheduleApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.PsuSchedule">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.TodayWidgetReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/widget_name">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/today_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".notify.ReminderReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".notify.BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
315
android/app/src/main/kotlin/ritm/schedule/MainActivity.kt
Normal file
315
android/app/src/main/kotlin/ritm/schedule/MainActivity.kt
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
package ritm.schedule
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.outlined.Home
|
||||||
|
import androidx.compose.material.icons.outlined.School
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material.icons.outlined.TravelExplore
|
||||||
|
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.rounded.Home
|
||||||
|
import androidx.compose.material.icons.rounded.Menu
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material.icons.rounded.School
|
||||||
|
import androidx.compose.material.icons.rounded.SwapHoriz
|
||||||
|
import androidx.compose.material.icons.rounded.TravelExplore
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalWideNavigationRail
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.WideNavigationRail
|
||||||
|
import androidx.compose.material3.WideNavigationRailDefaults
|
||||||
|
import androidx.compose.material3.WideNavigationRailItem
|
||||||
|
import androidx.compose.material3.rememberWideNavigationRailState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ritm.schedule.data.ThemeMode
|
||||||
|
import ritm.schedule.data.today
|
||||||
|
import ritm.schedule.ui.AppNav
|
||||||
|
import ritm.schedule.ui.GroupsScreen
|
||||||
|
import ritm.schedule.ui.HomeScreen
|
||||||
|
import ritm.schedule.ui.LocalDisplay
|
||||||
|
import ritm.schedule.ui.LocalSubjectSlots
|
||||||
|
import ritm.schedule.data.Display
|
||||||
|
import ritm.schedule.ui.MainViewModel
|
||||||
|
import ritm.schedule.ui.ScheduleScreen
|
||||||
|
import ritm.schedule.ui.SearchScreen
|
||||||
|
import ritm.schedule.ui.SettingsScreen
|
||||||
|
import ritm.schedule.ui.SubjectsScreen
|
||||||
|
import ritm.schedule.ui.subjectSlots
|
||||||
|
import ritm.schedule.ui.theme.ScheduleTheme
|
||||||
|
import ritm.schedule.ui.theme.isDark
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
private enum class Tab(val label: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
||||||
|
Home("Обзор", Icons.Outlined.Home, Icons.Rounded.Home),
|
||||||
|
Schedule("Расписание", Icons.Outlined.CalendarMonth, Icons.Rounded.CalendarMonth),
|
||||||
|
Subjects("Предметы", Icons.Outlined.School, Icons.Rounded.School),
|
||||||
|
Search("Поиск", Icons.Outlined.TravelExplore, Icons.Rounded.TravelExplore),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pages opened from the menu or the app bar; they cover the tabs until the student goes back. */
|
||||||
|
private enum class Page { Groups, Settings }
|
||||||
|
|
||||||
|
// The translucent scrims Android uses behind three-button navigation.
|
||||||
|
private val LIGHT_SCRIM = Color.argb(0xe6, 0xff, 0xff, 0xff)
|
||||||
|
private val DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val vm: MainViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
|
val s = settings
|
||||||
|
val dark = isDark(s?.theme ?: ThemeMode.System)
|
||||||
|
// System bar icons follow the app's own theme, which may differ from the phone's.
|
||||||
|
DisposableEffect(dark) {
|
||||||
|
enableEdgeToEdge(
|
||||||
|
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
|
||||||
|
navigationBarStyle = SystemBarStyle.auto(LIGHT_SCRIM, DARK_SCRIM) { dark },
|
||||||
|
)
|
||||||
|
onDispose {}
|
||||||
|
}
|
||||||
|
ScheduleTheme(mode = s?.theme ?: ThemeMode.System, dynamicColor = s?.dynamicColor ?: false) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Wait for the stored settings so a returning student never sees the first-run screen.
|
||||||
|
if (s != null) App(vm, firstRun = s.current == null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Window width from which the menu turns into a permanent side rail (M3 medium window class). */
|
||||||
|
private val MEDIUM_WIDTH = 600.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun App(vm: MainViewModel, firstRun: Boolean) {
|
||||||
|
var tab by rememberSaveable { mutableStateOf(Tab.Home) }
|
||||||
|
var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) }
|
||||||
|
var weekView by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var focus by remember { mutableStateOf<LocalDate?>(null) }
|
||||||
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
|
val hasGroup = settings?.current != null
|
||||||
|
val menu = rememberWideNavigationRailState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// Back closes a page first, then returns to the overview instead of closing the app.
|
||||||
|
BackHandler(enabled = page != null && hasGroup) { page = null }
|
||||||
|
BackHandler(enabled = page == null && tab != Tab.Home) { tab = Tab.Home }
|
||||||
|
|
||||||
|
val slots = remember(timetable) { subjectSlots(timetable) }
|
||||||
|
val motion = MaterialTheme.motionScheme
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
val wide = maxWidth >= MEDIUM_WIDTH
|
||||||
|
val nav = remember(wide) {
|
||||||
|
AppNav(
|
||||||
|
openGroups = { page = Page.Groups },
|
||||||
|
openSettings = { page = Page.Settings },
|
||||||
|
openDay = { focus = it; weekView = false; tab = Tab.Schedule },
|
||||||
|
openWeek = { focus = today(); weekView = true; tab = Tab.Schedule },
|
||||||
|
openSubjects = { tab = Tab.Subjects },
|
||||||
|
openSearch = { tab = Tab.Search },
|
||||||
|
openMenu = if (wide) null else ({ scope.launch { menu.expand() } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(LocalSubjectSlots provides slots, LocalDisplay provides (settings?.display ?: Display())) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = page,
|
||||||
|
transitionSpec = {
|
||||||
|
(slideInHorizontally(motion.defaultSpatialSpec()) { it / 5 } + fadeIn(motion.defaultEffectsSpec())) togetherWith
|
||||||
|
fadeOut(motion.fastEffectsSpec())
|
||||||
|
},
|
||||||
|
label = "page",
|
||||||
|
) { current ->
|
||||||
|
// Pages pad for side insets; their lists end above the navigation bar themselves.
|
||||||
|
val pageInsets = Modifier.fillMaxSize()
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
|
||||||
|
when (current) {
|
||||||
|
Page.Groups -> Box(pageInsets) {
|
||||||
|
GroupsScreen(
|
||||||
|
vm,
|
||||||
|
onOpened = { page = null; tab = Tab.Home },
|
||||||
|
onBack = if (hasGroup) ({ page = null }) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Page.Settings -> Box(pageInsets) { SettingsScreen(vm, onBack = { page = null }) }
|
||||||
|
null -> Tabs(tab, wide, onTab = { tab = it }, onMenu = { scope.launch { menu.expand() } }) { shown ->
|
||||||
|
when (shown) {
|
||||||
|
Tab.Home -> HomeScreen(vm, nav)
|
||||||
|
Tab.Schedule -> ScheduleScreen(
|
||||||
|
vm, nav,
|
||||||
|
weekView = weekView,
|
||||||
|
onWeekView = { weekView = it },
|
||||||
|
focus = focus,
|
||||||
|
onFocusHandled = { focus = null },
|
||||||
|
)
|
||||||
|
Tab.Subjects -> SubjectsScreen(vm, nav)
|
||||||
|
Tab.Search -> SearchScreen(vm, nav)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The side menu slides over the screen, as in School Planner: from the app bar's
|
||||||
|
// hamburger on phones, from the rail's menu button on wider windows. It opens in its
|
||||||
|
// own window that styles the system bars by the night mode it reads, so it gets the app's.
|
||||||
|
CompositionLocalProvider(LocalConfiguration provides themedConfiguration(isDark(settings?.theme ?: ThemeMode.System))) {
|
||||||
|
ModalWideNavigationRail(
|
||||||
|
state = menu,
|
||||||
|
hideOnCollapse = true,
|
||||||
|
colors = WideNavigationRailDefaults.colors(modalContainerColor = MaterialTheme.colorScheme.page),
|
||||||
|
header = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.app_name),
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, top = 8.dp, bottom = 16.dp).semantics { heading() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
fun close(action: () -> Unit) {
|
||||||
|
action()
|
||||||
|
scope.launch { menu.collapse() }
|
||||||
|
}
|
||||||
|
Tab.entries.forEach { item ->
|
||||||
|
val selected = page == null && tab == item
|
||||||
|
WideNavigationRailItem(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { close { page = null; tab = item } },
|
||||||
|
icon = { Icon(if (selected) item.selectedIcon else item.icon, contentDescription = null) },
|
||||||
|
label = { Text(item.label) },
|
||||||
|
railExpanded = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 8.dp))
|
||||||
|
WideNavigationRailItem(
|
||||||
|
selected = page == Page.Groups,
|
||||||
|
onClick = { close { page = Page.Groups } },
|
||||||
|
icon = { Icon(Icons.Rounded.SwapHoriz, contentDescription = null) },
|
||||||
|
label = { Text("Сменить группу") },
|
||||||
|
railExpanded = true,
|
||||||
|
)
|
||||||
|
WideNavigationRailItem(
|
||||||
|
selected = page == Page.Settings,
|
||||||
|
onClick = { close { page = Page.Settings } },
|
||||||
|
icon = { Icon(Icons.Outlined.Settings, contentDescription = null) },
|
||||||
|
label = { Text("Настройки") },
|
||||||
|
railExpanded = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current configuration with its night mode set to the app's theme. */
|
||||||
|
@Composable
|
||||||
|
private fun themedConfiguration(dark: Boolean): Configuration {
|
||||||
|
val base = LocalConfiguration.current
|
||||||
|
return remember(base, dark) {
|
||||||
|
Configuration(base).apply {
|
||||||
|
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
||||||
|
if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On medium and wider windows a permanent rail on the tinted page; on phones the screen alone. */
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun Tabs(tab: Tab, wide: Boolean, onTab: (Tab) -> Unit, onMenu: () -> Unit, content: @Composable (Tab) -> Unit) {
|
||||||
|
val motion = MaterialTheme.motionScheme
|
||||||
|
// The screen keeps one place in the tree whether the rail is shown or not,
|
||||||
|
// so rotating the phone keeps its saved state (selected day, week view).
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
if (wide) WideNavigationRail(
|
||||||
|
colors = WideNavigationRailDefaults.colors(containerColor = MaterialTheme.colorScheme.page),
|
||||||
|
header = {
|
||||||
|
IconButton(onClick = onMenu, modifier = Modifier.padding(start = 24.dp)) {
|
||||||
|
Icon(Icons.Rounded.Menu, contentDescription = "Открыть меню")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Tab.entries.forEach { item ->
|
||||||
|
WideNavigationRailItem(
|
||||||
|
selected = tab == item,
|
||||||
|
onClick = { onTab(item) },
|
||||||
|
icon = { Icon(if (tab == item) item.selectedIcon else item.icon, contentDescription = null) },
|
||||||
|
label = { Text(item.label) },
|
||||||
|
railExpanded = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.then(
|
||||||
|
// The rail already sits over the start inset (cutout in landscape),
|
||||||
|
// so the screens' top app bars must not pad for it again.
|
||||||
|
if (wide) Modifier
|
||||||
|
.consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.Start))
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.End))
|
||||||
|
else Modifier,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = tab,
|
||||||
|
transitionSpec = { fadeIn(motion.defaultEffectsSpec()) togetherWith fadeOut(motion.fastEffectsSpec()) },
|
||||||
|
label = "tab",
|
||||||
|
) { content(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
android/app/src/main/kotlin/ritm/schedule/ScheduleApp.kt
Normal file
25
android/app/src/main/kotlin/ritm/schedule/ScheduleApp.kt
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package ritm.schedule
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import ritm.schedule.data.Api
|
||||||
|
import ritm.schedule.data.Repository
|
||||||
|
import ritm.schedule.data.Settings
|
||||||
|
import ritm.schedule.notify.Reminders
|
||||||
|
import ritm.schedule.work.SyncWorker
|
||||||
|
|
||||||
|
class ScheduleApp : Application() {
|
||||||
|
lateinit var settings: Settings
|
||||||
|
private set
|
||||||
|
lateinit var repository: Repository
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
settings = Settings(this)
|
||||||
|
repository = Repository(this, Api(BuildConfig.API_URL), settings)
|
||||||
|
Reminders.createChannel(this)
|
||||||
|
SyncWorker.schedule(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val android.content.Context.app: ScheduleApp get() = applicationContext as ScheduleApp
|
||||||
82
android/app/src/main/kotlin/ritm/schedule/data/Api.kt
Normal file
82
android/app/src/main/kotlin/ritm/schedule/data/Api.kt
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package ritm.schedule.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class ApiException(message: String, val offline: Boolean = false) : Exception(message)
|
||||||
|
|
||||||
|
val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The schedule server's HTTP API. Every error message is ready to show. */
|
||||||
|
class Api(baseUrl: String) {
|
||||||
|
private val base: HttpUrl = baseUrl.toHttpUrl()
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
// The first request after a server start can wait for the catalog index.
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> get(path: String, vararg query: Pair<String, String?>): T =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val url = base.newBuilder().addPathSegments(path.trimStart('/')).apply {
|
||||||
|
for ((name, value) in query) if (!value.isNullOrEmpty()) addQueryParameter(name, value)
|
||||||
|
}.build()
|
||||||
|
val body = try {
|
||||||
|
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val message = runCatching {
|
||||||
|
json.parseToJsonElement(text).jsonObject["error"]?.jsonPrimitive?.content
|
||||||
|
}.getOrNull()
|
||||||
|
throw ApiException(message ?: "Сервер ответил ошибкой ${response.code}.")
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw ApiException("Нет связи с сервером расписания. Показана сохранённая копия.", offline = true)
|
||||||
|
}
|
||||||
|
json.decodeFromString<T>(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun timetable(docId: String, group: String, from: String, days: Int) =
|
||||||
|
get<Timetable>("api/timetable", "doc" to docId, "group" to group, "from" to from, "days" to days.toString())
|
||||||
|
|
||||||
|
suspend fun groups(query: String = "", faculty: String = "", course: String = "", form: String = "") =
|
||||||
|
get<GroupsResponse>("api/groups", "q" to query, "faculty" to faculty, "course" to course, "form" to form)
|
||||||
|
|
||||||
|
suspend fun searchDirectory(query: String) = get<DirectorySearch>("api/directory/search", "q" to query)
|
||||||
|
|
||||||
|
suspend fun teacherDay(name: String, date: String) =
|
||||||
|
get<DirectoryDay>("api/directory/day", "teacher" to name, "date" to date)
|
||||||
|
|
||||||
|
suspend fun roomDay(room: String, date: String) =
|
||||||
|
get<DirectoryDay>("api/directory/day", "room" to room, "date" to date)
|
||||||
|
|
||||||
|
suspend fun freeRooms(date: String, pair: Int) =
|
||||||
|
get<FreeRooms>("api/directory/free", "date" to date, "pair" to pair.toString())
|
||||||
|
|
||||||
|
suspend fun calendar() = get<CalendarInfo>("api/calendar")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** runCatching that lets coroutine cancellation through: a cancelled request is not an error. */
|
||||||
|
suspend inline fun <T> attempt(crossinline block: suspend () -> T): Result<T> =
|
||||||
|
try {
|
||||||
|
Result.success(block())
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
48
android/app/src/main/kotlin/ritm/schedule/data/Dates.kt
Normal file
48
android/app/src/main/kotlin/ritm/schedule/data/Dates.kt
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package ritm.schedule.data
|
||||||
|
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** The university's time zone: dates and "now" never follow the phone's zone. */
|
||||||
|
val MINSK: ZoneId = ZoneId.of("Europe/Minsk")
|
||||||
|
private val RU = Locale.forLanguageTag("ru")
|
||||||
|
|
||||||
|
fun today(): LocalDate = LocalDate.now(MINSK)
|
||||||
|
fun nowTime(): LocalTime = LocalTime.now(MINSK)
|
||||||
|
fun nowDateTime(): LocalDateTime = LocalDateTime.now(MINSK)
|
||||||
|
|
||||||
|
fun LocalDate.monday(): LocalDate = with(DayOfWeek.MONDAY)
|
||||||
|
fun parseDate(iso: String): LocalDate = LocalDate.parse(iso)
|
||||||
|
fun parseTime(hhmm: String): LocalTime = LocalTime.parse(hhmm)
|
||||||
|
|
||||||
|
fun LocalDate.longLabel(): String = format(DateTimeFormatter.ofPattern("d MMMM", RU))
|
||||||
|
fun LocalDate.shortWeekday(): String =
|
||||||
|
dayOfWeek.getDisplayName(TextStyle.SHORT_STANDALONE, RU).replaceFirstChar { it.titlecase(RU) }
|
||||||
|
fun LocalDate.weekdayName(): String =
|
||||||
|
dayOfWeek.getDisplayName(TextStyle.FULL_STANDALONE, RU).replaceFirstChar { it.titlecase(RU) }
|
||||||
|
fun dayName(day: Int): String =
|
||||||
|
DayOfWeek.of(day).getDisplayName(TextStyle.FULL_STANDALONE, RU).replaceFirstChar { it.titlecase(RU) }
|
||||||
|
|
||||||
|
/** "12–18 октября" or "28 сентября – 4 октября". */
|
||||||
|
fun weekSpan(start: LocalDate, end: LocalDate): String =
|
||||||
|
if (start.month == end.month) "${start.dayOfMonth}–${end.longLabel()}"
|
||||||
|
else "${start.longLabel()} – ${end.longLabel()}"
|
||||||
|
|
||||||
|
fun plural(n: Int, one: String, few: String, many: String): String {
|
||||||
|
val tail = n % 100
|
||||||
|
return when {
|
||||||
|
tail in 11..14 -> many
|
||||||
|
n % 10 == 1 -> one
|
||||||
|
n % 10 in 2..4 -> few
|
||||||
|
else -> many
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun minutesLabel(m: Long): String =
|
||||||
|
if (m < 60) "$m мин" else "${m / 60} ч" + if (m % 60 > 0) " ${m % 60} мин" else ""
|
||||||
154
android/app/src/main/kotlin/ritm/schedule/data/Models.kt
Normal file
154
android/app/src/main/kotlin/ritm/schedule/data/Models.kt
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package ritm.schedule.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// Shapes of the server JSON (see server/timetable.js and server/directory.js).
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Timetable(
|
||||||
|
val group: String,
|
||||||
|
val document: DocumentRef,
|
||||||
|
val period: String? = null,
|
||||||
|
val semester: Semester? = null,
|
||||||
|
val note: String? = null,
|
||||||
|
val subgroups: List<String> = emptyList(),
|
||||||
|
val bells: List<Bell> = emptyList(),
|
||||||
|
val from: String,
|
||||||
|
val days: List<Day> = emptyList(),
|
||||||
|
val weekly: List<WeeklyDay> = emptyList(),
|
||||||
|
val notes: List<String> = emptyList(),
|
||||||
|
val generatedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DocumentRef(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val faculty: String = "",
|
||||||
|
val course: String = "",
|
||||||
|
val modified: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Semester(val id: String, val label: String, val start: String, val end: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Bell(val pair: Int, val start: String, val end: String, val halves: List<List<String>> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Week(val n: Int, val color: String) {
|
||||||
|
val white get() = color == "white"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Day(val date: String, val weekday: Int, val week: Week? = null, val lessons: List<Lesson> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Lesson(
|
||||||
|
val key: String,
|
||||||
|
val start: String,
|
||||||
|
val end: String? = null,
|
||||||
|
val pair: Int? = null,
|
||||||
|
val subject: String,
|
||||||
|
val type: String = "",
|
||||||
|
val teachers: List<String> = emptyList(),
|
||||||
|
val rooms: List<String> = emptyList(),
|
||||||
|
val roomMoved: Boolean = false,
|
||||||
|
val distance: Boolean = false,
|
||||||
|
val condition: String = "",
|
||||||
|
val variant: String = "all",
|
||||||
|
val subgroup: String? = null,
|
||||||
|
val groups: List<String> = emptyList(),
|
||||||
|
val uncertain: Boolean = false,
|
||||||
|
val raw: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GroupsResponse(
|
||||||
|
val results: List<GroupResult> = emptyList(),
|
||||||
|
val facets: Facets = Facets(),
|
||||||
|
val total: Int = 0,
|
||||||
|
val year: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GroupResult(
|
||||||
|
val group: String,
|
||||||
|
val fileId: String,
|
||||||
|
val file: String = "",
|
||||||
|
val faculty: String = "",
|
||||||
|
val course: String = "",
|
||||||
|
val form: String = "",
|
||||||
|
val period: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Facets(
|
||||||
|
val faculties: List<Facet> = emptyList(),
|
||||||
|
val courses: List<Facet> = emptyList(),
|
||||||
|
val forms: List<Facet> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Facet(val name: String, val count: Int)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DirectorySearch(val teachers: List<TeacherHit> = emptyList(), val rooms: List<RoomHit> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TeacherHit(val name: String, val count: Int = 0, val faculties: List<String> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RoomHit(val room: String, val count: Int = 0)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DirectoryDay(val date: String, val lessons: List<DirectoryLesson> = emptyList(), val coverage: Coverage)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DirectoryLesson(
|
||||||
|
val start: String,
|
||||||
|
val end: String? = null,
|
||||||
|
val subject: String,
|
||||||
|
val type: String = "",
|
||||||
|
val teachers: List<String> = emptyList(),
|
||||||
|
val rooms: List<String> = emptyList(),
|
||||||
|
val moved: Boolean = false,
|
||||||
|
val distance: Boolean = false,
|
||||||
|
val groups: List<String> = emptyList(),
|
||||||
|
val condition: String = "",
|
||||||
|
val uncertain: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Coverage(
|
||||||
|
val documents: Int,
|
||||||
|
val total: Int,
|
||||||
|
val failed: List<String> = emptyList(),
|
||||||
|
val withoutSemester: List<String> = emptyList(),
|
||||||
|
val year: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class FreeRooms(
|
||||||
|
val date: String,
|
||||||
|
val pair: Int,
|
||||||
|
val bell: Bell,
|
||||||
|
val free: List<RoomGroup> = emptyList(),
|
||||||
|
val freeCount: Int = 0,
|
||||||
|
val busyCount: Int = 0,
|
||||||
|
val knownRooms: Int = 0,
|
||||||
|
val coverage: Coverage,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RoomGroup(val letter: String, val rooms: List<String>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CalendarInfo(val bells: List<Bell> = emptyList())
|
||||||
|
|
||||||
|
/** A group the student opened; kept on the device for quick switching. */
|
||||||
|
@Serializable
|
||||||
|
data class SavedGroup(val docId: String, val group: String, val faculty: String = "", val course: String = "")
|
||||||
55
android/app/src/main/kotlin/ritm/schedule/data/Repository.kt
Normal file
55
android/app/src/main/kotlin/ritm/schedule/data/Repository.kt
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package ritm.schedule.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The open group's timetable: the server computes dates, the app keeps the last
|
||||||
|
* answer on disk so the schedule, widget and reminders work without a network.
|
||||||
|
*/
|
||||||
|
class Repository(context: Context, val api: Api, private val settings: Settings) {
|
||||||
|
private val cache = File(context.filesDir, "timetable.json")
|
||||||
|
private val _timetable = MutableStateFlow(readCache())
|
||||||
|
val timetable: StateFlow<Timetable?> = _timetable
|
||||||
|
|
||||||
|
/** Five weeks from this Monday: enough to browse ahead and to plan reminders. */
|
||||||
|
val span = 35
|
||||||
|
|
||||||
|
private fun readCache(): Timetable? =
|
||||||
|
runCatching { json.decodeFromString<Timetable>(cache.readText()) }.getOrNull()
|
||||||
|
|
||||||
|
suspend fun refresh(): Result<Timetable> = withContext(Dispatchers.IO) {
|
||||||
|
val group = settings.now().current
|
||||||
|
?: return@withContext Result.failure(ApiException("Группа не выбрана."))
|
||||||
|
attempt {
|
||||||
|
val from = today().monday()
|
||||||
|
val fresh = api.timetable(group.docId, group.group, from.toString(), span)
|
||||||
|
cache.writeText(json.encodeToString(Timetable.serializer(), fresh))
|
||||||
|
_timetable.value = fresh
|
||||||
|
fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun open(result: GroupResult): Result<Timetable> {
|
||||||
|
settings.openGroup(SavedGroup(result.fileId, result.group, result.faculty, result.course))
|
||||||
|
return refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun open(saved: SavedGroup): Result<Timetable> {
|
||||||
|
settings.openGroup(saved)
|
||||||
|
return refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lessons of a date after the student's subgroup filter. */
|
||||||
|
fun lessonsOn(timetable: Timetable, date: LocalDate, subgroup: String?): List<Lesson> =
|
||||||
|
timetable.days.firstOrNull { it.date == date.toString() }?.lessons.orEmpty()
|
||||||
|
.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
||||||
|
|
||||||
|
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
|
||||||
|
timetable.days.firstOrNull { it.date == date.toString() }
|
||||||
|
}
|
||||||
121
android/app/src/main/kotlin/ritm/schedule/data/Settings.kt
Normal file
121
android/app/src/main/kotlin/ritm/schedule/data/Settings.kt
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package ritm.schedule.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
enum class ThemeMode { System, Light, Dark }
|
||||||
|
|
||||||
|
/** What lesson cards, the week grid and the overview show; everything is on by default. */
|
||||||
|
data class Display(
|
||||||
|
val colors: Boolean = true,
|
||||||
|
val teachers: Boolean = true,
|
||||||
|
val rooms: Boolean = true,
|
||||||
|
val pairs: Boolean = true,
|
||||||
|
val tags: Boolean = true,
|
||||||
|
val notes: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserSettings(
|
||||||
|
val current: SavedGroup? = null,
|
||||||
|
val subgroup: String? = null,
|
||||||
|
val theme: ThemeMode = ThemeMode.System,
|
||||||
|
val dynamicColor: Boolean = false,
|
||||||
|
val reminders: Boolean = false,
|
||||||
|
val reminderMinutes: Int = 10,
|
||||||
|
val recent: List<SavedGroup> = emptyList(),
|
||||||
|
val display: Display = Display(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val Context.store by preferencesDataStore("settings")
|
||||||
|
|
||||||
|
/** Everything the student chose; stays on the device. */
|
||||||
|
class Settings(private val context: Context) {
|
||||||
|
private object Keys {
|
||||||
|
val current = stringPreferencesKey("current")
|
||||||
|
val subgroups = stringPreferencesKey("subgroups")
|
||||||
|
val theme = stringPreferencesKey("theme")
|
||||||
|
val dynamic = booleanPreferencesKey("dynamic_color")
|
||||||
|
val reminders = booleanPreferencesKey("reminders")
|
||||||
|
val minutes = intPreferencesKey("reminder_minutes")
|
||||||
|
val recent = stringPreferencesKey("recent")
|
||||||
|
val colors = booleanPreferencesKey("show_colors")
|
||||||
|
val teachers = booleanPreferencesKey("show_teachers")
|
||||||
|
val rooms = booleanPreferencesKey("show_rooms")
|
||||||
|
val pairs = booleanPreferencesKey("show_pairs")
|
||||||
|
val tags = booleanPreferencesKey("show_tags")
|
||||||
|
val notes = booleanPreferencesKey("show_notes")
|
||||||
|
}
|
||||||
|
|
||||||
|
val flow: Flow<UserSettings> = context.store.data.map { it.toSettings() }
|
||||||
|
|
||||||
|
suspend fun now(): UserSettings = flow.first()
|
||||||
|
|
||||||
|
private fun Preferences.toSettings(): UserSettings {
|
||||||
|
val current = this[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
|
||||||
|
val subgroups = this[Keys.subgroups]?.let {
|
||||||
|
runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull()
|
||||||
|
}.orEmpty()
|
||||||
|
return UserSettings(
|
||||||
|
current = current,
|
||||||
|
subgroup = current?.let { subgroups[subgroupKey(it)] },
|
||||||
|
theme = this[Keys.theme]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() } ?: ThemeMode.System,
|
||||||
|
dynamicColor = this[Keys.dynamic] ?: false,
|
||||||
|
reminders = this[Keys.reminders] ?: false,
|
||||||
|
reminderMinutes = this[Keys.minutes] ?: 10,
|
||||||
|
recent = this[Keys.recent]?.let {
|
||||||
|
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
|
||||||
|
}.orEmpty(),
|
||||||
|
display = Display(
|
||||||
|
colors = this[Keys.colors] ?: true,
|
||||||
|
teachers = this[Keys.teachers] ?: true,
|
||||||
|
rooms = this[Keys.rooms] ?: true,
|
||||||
|
pairs = this[Keys.pairs] ?: true,
|
||||||
|
tags = this[Keys.tags] ?: true,
|
||||||
|
notes = this[Keys.notes] ?: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun subgroupKey(g: SavedGroup) = "${g.docId}|${g.group}"
|
||||||
|
|
||||||
|
suspend fun openGroup(group: SavedGroup) = context.store.edit { prefs ->
|
||||||
|
prefs[Keys.current] = json.encodeToString(group)
|
||||||
|
val recent = prefs[Keys.recent]?.let {
|
||||||
|
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
|
||||||
|
}.orEmpty()
|
||||||
|
val next = (listOf(group) + recent.filterNot { it.docId == group.docId && it.group == group.group }).take(8)
|
||||||
|
prefs[Keys.recent] = json.encodeToString(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSubgroup(label: String?) = context.store.edit { prefs ->
|
||||||
|
val current = prefs[Keys.current]?.let { runCatching { json.decodeFromString<SavedGroup>(it) }.getOrNull() }
|
||||||
|
?: return@edit
|
||||||
|
val map = prefs[Keys.subgroups]?.let {
|
||||||
|
runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull()
|
||||||
|
}.orEmpty().toMutableMap()
|
||||||
|
if (label == null) map.remove(subgroupKey(current)) else map[subgroupKey(current)] = label
|
||||||
|
prefs[Keys.subgroups] = json.encodeToString(map.toMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTheme(mode: ThemeMode) = context.store.edit { it[Keys.theme] = mode.name }
|
||||||
|
suspend fun setDynamicColor(on: Boolean) = context.store.edit { it[Keys.dynamic] = on }
|
||||||
|
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }
|
||||||
|
suspend fun setReminderMinutes(minutes: Int) = context.store.edit { it[Keys.minutes] = minutes }
|
||||||
|
|
||||||
|
suspend fun setDisplay(d: Display) = context.store.edit {
|
||||||
|
it[Keys.colors] = d.colors
|
||||||
|
it[Keys.teachers] = d.teachers
|
||||||
|
it[Keys.rooms] = d.rooms
|
||||||
|
it[Keys.pairs] = d.pairs
|
||||||
|
it[Keys.tags] = d.tags
|
||||||
|
it[Keys.notes] = d.notes
|
||||||
|
}
|
||||||
|
}
|
||||||
155
android/app/src/main/kotlin/ritm/schedule/notify/Reminders.kt
Normal file
155
android/app/src/main/kotlin/ritm/schedule/notify/Reminders.kt
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package ritm.schedule.notify
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ritm.schedule.MainActivity
|
||||||
|
import ritm.schedule.R
|
||||||
|
import ritm.schedule.app
|
||||||
|
import ritm.schedule.data.MINSK
|
||||||
|
import ritm.schedule.data.Timetable
|
||||||
|
import ritm.schedule.data.UserSettings
|
||||||
|
import ritm.schedule.data.parseDate
|
||||||
|
import ritm.schedule.data.parseTime
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local reminders before each lesson of the coming week. They come from the
|
||||||
|
* cached timetable, so they work offline; no push server is involved.
|
||||||
|
*/
|
||||||
|
object Reminders {
|
||||||
|
private const val CHANNEL = "before_lesson"
|
||||||
|
private const val MAX = 24
|
||||||
|
private const val ACTION = "ritm.schedule.REMIND"
|
||||||
|
private const val WINDOW = 10 * 60 * 1000L
|
||||||
|
|
||||||
|
/** Exact times need the student's consent on Android 12+ ("Будильники и напоминания"). */
|
||||||
|
fun canBeExact(context: Context) =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
||||||
|
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
||||||
|
|
||||||
|
fun createChannel(context: Context) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL,
|
||||||
|
context.getString(R.string.channel_reminders),
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply { description = "Напоминание за несколько минут до начала пары" }
|
||||||
|
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canNotify(context: Context) =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
private fun intent(context: Context, slot: Int, extras: Intent.() -> Unit = {}): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
slot,
|
||||||
|
Intent(context, ReminderReceiver::class.java).setAction(ACTION).apply(extras),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Replaces every scheduled reminder with the ones the timetable needs now. */
|
||||||
|
fun reschedule(context: Context, timetable: Timetable?, settings: UserSettings) {
|
||||||
|
val alarms = context.getSystemService(AlarmManager::class.java)
|
||||||
|
repeat(MAX) { slot -> alarms.cancel(intent(context, slot)) }
|
||||||
|
if (!settings.reminders || timetable == null) return
|
||||||
|
val now = Instant.now()
|
||||||
|
// A week ahead, so a Friday evening already holds Monday's reminders.
|
||||||
|
val horizon = now.plusSeconds(7 * 24 * 3600)
|
||||||
|
var slot = 0
|
||||||
|
for (day in timetable.days) {
|
||||||
|
val date = parseDate(day.date)
|
||||||
|
// One reminder per start time: parallel subgroups share the notification.
|
||||||
|
for ((start, lessons) in day.lessons
|
||||||
|
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
|
||||||
|
.groupBy { it.start }) {
|
||||||
|
val begins = date.atTime(parseTime(start)).atZone(MINSK).toInstant()
|
||||||
|
val at = begins.minusSeconds(settings.reminderMinutes * 60L)
|
||||||
|
if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue
|
||||||
|
val first = lessons.first()
|
||||||
|
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
|
||||||
|
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
|
||||||
|
val text = buildString {
|
||||||
|
append(start)
|
||||||
|
first.pair?.let { append(" · $it пара") }
|
||||||
|
if (place.isNotEmpty()) append(" · ауд. $place")
|
||||||
|
if (lessons.any { it.uncertain }) append(" · сверьте недели")
|
||||||
|
}
|
||||||
|
val pending = intent(context, slot) {
|
||||||
|
putExtra("title", title)
|
||||||
|
putExtra("text", text)
|
||||||
|
putExtra("id", slot)
|
||||||
|
}
|
||||||
|
if (canBeExact(context)) {
|
||||||
|
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at.toEpochMilli(), pending)
|
||||||
|
} else {
|
||||||
|
// Without exact alarms Android may deliver up to an hour late; a
|
||||||
|
// 10-minute window (the shortest allowed) centred on the time keeps it useful.
|
||||||
|
alarms.setWindow(AlarmManager.RTC_WAKEUP, at.toEpochMilli() - WINDOW / 2, WINDOW, pending)
|
||||||
|
}
|
||||||
|
slot++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun show(context: Context, id: Int, title: String, text: String) {
|
||||||
|
if (!canNotify(context)) return
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
@Suppress("MissingPermission")
|
||||||
|
NotificationManagerCompat.from(context).notify(1000 + id, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReminderReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
Reminders.show(
|
||||||
|
context,
|
||||||
|
intent.getIntExtra("id", 0),
|
||||||
|
intent.getStringExtra("title") ?: return,
|
||||||
|
intent.getStringExtra("text").orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alarms do not survive a reboot or an app update; set them again from the cache. */
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val pending = goAsync()
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
try {
|
||||||
|
val app = context.app
|
||||||
|
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
|
||||||
|
} finally {
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
485
android/app/src/main/kotlin/ritm/schedule/ui/Common.kt
Normal file
485
android/app/src/main/kotlin/ritm/schedule/ui/Common.kt
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import ritm.schedule.ui.theme.card
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
|
import kotlin.math.abs
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import androidx.compose.material.icons.rounded.Close
|
||||||
|
import androidx.compose.material.icons.rounded.Search
|
||||||
|
import androidx.compose.material3.ButtonGroupDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.SegmentedListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SearchBarDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.ToggleButton
|
||||||
|
import androidx.compose.material3.ToggleButtonDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarColors
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
|
import androidx.compose.material3.rememberSearchBarState
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ritm.schedule.data.Week
|
||||||
|
import ritm.schedule.data.shortWeekday
|
||||||
|
import ritm.schedule.data.today
|
||||||
|
import ritm.schedule.data.weekSpan
|
||||||
|
import ritm.schedule.ui.theme.WeekColors
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** App bars sit on the tinted page and take a deeper tint once content scrolls under them. */
|
||||||
|
@Composable
|
||||||
|
fun appBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.page,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Lists scroll under the translucent navigation bar and end above it. */
|
||||||
|
@Composable
|
||||||
|
fun navBarPadding(): Dp = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
|
||||||
|
/** Readable line length on wide windows (M3 keeps content under 840dp). */
|
||||||
|
val MAX_CONTENT = 720.dp
|
||||||
|
|
||||||
|
/** Centres content and caps its width on tablets, foldables and landscape phones. */
|
||||||
|
@Composable
|
||||||
|
fun ContentColumn(content: @Composable BoxScope.() -> Unit) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||||
|
Box(Modifier.widthIn(max = MAX_CONTENT).fillMaxSize(), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A section or screen title that TalkBack announces as a heading. */
|
||||||
|
@Composable
|
||||||
|
fun Heading(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: TextStyle = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
) {
|
||||||
|
Text(text, style = style, modifier = modifier.semantics { heading() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M3 Expressive connected button group: one choice out of a few. */
|
||||||
|
@Composable
|
||||||
|
fun <T> ConnectedChoice(
|
||||||
|
options: List<Pair<T, String>>,
|
||||||
|
selected: T,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier.selectableGroup(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
||||||
|
) {
|
||||||
|
options.forEachIndexed { index, (value, label) ->
|
||||||
|
val checked = value == selected
|
||||||
|
ToggleButton(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { onSelect(value) },
|
||||||
|
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
|
||||||
|
// Unselected segments need their own tonal fill to read as one group.
|
||||||
|
colors = ToggleButtonDefaults.colors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
checkedContainerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
checkedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
shapes = when (index) {
|
||||||
|
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
|
||||||
|
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||||||
|
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (checked) {
|
||||||
|
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M3 search input field on a tonal container; reports every edit through [onQueryChange]. */
|
||||||
|
@Composable
|
||||||
|
fun SearchField(query: String, onQueryChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier) {
|
||||||
|
val text = rememberTextFieldState(query)
|
||||||
|
val bar = rememberSearchBarState()
|
||||||
|
LaunchedEffect(text) {
|
||||||
|
snapshotFlow { text.text.toString() }.collect { onQueryChange(it) }
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
shape = SearchBarDefaults.inputFieldShape,
|
||||||
|
color = MaterialTheme.colorScheme.card,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
SearchBarDefaults.InputField(
|
||||||
|
textFieldState = text,
|
||||||
|
searchBarState = bar,
|
||||||
|
onSearch = {},
|
||||||
|
placeholder = { Text(placeholder, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (text.text.isNotEmpty()) IconButton(onClick = { text.clearText() }) {
|
||||||
|
Icon(Icons.Rounded.Close, contentDescription = "Очистить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corners of an item in a segmented group: extra-large outside, small between items. */
|
||||||
|
@Composable
|
||||||
|
fun segmentShape(index: Int, count: Int): RoundedCornerShape {
|
||||||
|
val outer = MaterialTheme.shapes.extraLarge
|
||||||
|
val inner = MaterialTheme.shapes.small
|
||||||
|
val first = index == 0
|
||||||
|
val last = index == count - 1
|
||||||
|
return RoundedCornerShape(
|
||||||
|
topStart = if (first) outer.topStart else inner.topStart,
|
||||||
|
topEnd = if (first) outer.topEnd else inner.topEnd,
|
||||||
|
bottomEnd = if (last) outer.bottomEnd else inner.bottomEnd,
|
||||||
|
bottomStart = if (last) outer.bottomStart else inner.bottomStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An M3 Expressive list item in a segmented list: shapes and colours from ListItemDefaults. */
|
||||||
|
@Composable
|
||||||
|
fun SegmentedItem(
|
||||||
|
index: Int,
|
||||||
|
count: Int,
|
||||||
|
headline: String,
|
||||||
|
supporting: String?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
SegmentedListItem(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
leadingContent = leading,
|
||||||
|
trailingContent = trailing,
|
||||||
|
supportingContent = supporting?.takeIf { it.isNotEmpty() }?.let { text -> { Text(text) } },
|
||||||
|
shapes = ListItemDefaults.segmentedShapes(index, count),
|
||||||
|
// Segments are cards on the tinted page.
|
||||||
|
colors = ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.card),
|
||||||
|
) {
|
||||||
|
Text(headline, style = MaterialTheme.typography.titleMediumEmphasized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items of a segmented list sit the M3 segmented gap apart. */
|
||||||
|
@Composable
|
||||||
|
fun SegmentedList(content: @Composable () -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tonal circular container for a list item's leading icon. */
|
||||||
|
@Composable
|
||||||
|
fun LeadingIcon(icon: ImageVector) {
|
||||||
|
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.size(40.dp)) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.padding(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Неделя 7 [белая] 12–18 октября" with arrows to the neighbouring weeks. */
|
||||||
|
@Composable
|
||||||
|
fun WeekHeader(
|
||||||
|
week: Week?,
|
||||||
|
monday: LocalDate,
|
||||||
|
onPrevious: (() -> Unit)?,
|
||||||
|
onNext: (() -> Unit)?,
|
||||||
|
trailing: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
IconButton(onClick = { onPrevious?.invoke() }, enabled = onPrevious != null) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowLeft, contentDescription = "Предыдущая неделя")
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Heading(week?.let { "Неделя ${it.n}" } ?: "Вне таблицы недель")
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
weekSpan(monday, monday.plusDays(6)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
week?.let {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
WeekMark(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailing()
|
||||||
|
IconButton(onClick = { onNext?.invoke() }, enabled = onNext != null) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = "Следующая неделя")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "● зелёная неделя": a small mark in the week's own colour next to a calm label. */
|
||||||
|
@Composable
|
||||||
|
fun WeekMark(week: Week, modifier: Modifier = Modifier, short: Boolean = false) {
|
||||||
|
val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.background(if (week.white) Color.White else WeekColors.mark(dark), CircleShape)
|
||||||
|
.then(if (week.white) Modifier.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) else Modifier),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
(if (week.white) "белая" else "зелёная") + if (short) "" else " неделя",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The schedule header's background: green weeks tint it softly, white weeks keep the page.
|
||||||
|
* Swiping between weeks fades one into the other.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun weekTint(week: Week?): Color =
|
||||||
|
animateColorAsState(weekTintOf(week), MaterialTheme.motionScheme.defaultEffectsSpec(), label = "week-tint").value
|
||||||
|
|
||||||
|
/** [weekTint] without the fade: each page of a week pager carries its own week's colour. */
|
||||||
|
@Composable
|
||||||
|
fun weekTintOf(week: Week?): Color {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val dark = scheme.surface.luminance() < 0.5f
|
||||||
|
return if (week == null || week.white) scheme.page
|
||||||
|
else lerp(scheme.page, WeekColors.container(false, dark), if (dark) 0.55f else 0.75f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seven days of a week in School Planner's calm style: the weekday, the date in a circle,
|
||||||
|
* a filled circle for the selected day and primary text for today. Swiping between weeks
|
||||||
|
* is the caller's pager; TalkBack gets the same moves as custom actions.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DayStrip(
|
||||||
|
monday: LocalDate,
|
||||||
|
selected: LocalDate?,
|
||||||
|
hasLessons: (LocalDate) -> Boolean,
|
||||||
|
enabled: (LocalDate) -> Boolean = { true },
|
||||||
|
onSelect: (LocalDate) -> Unit,
|
||||||
|
onWeek: ((Int) -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectableGroup()
|
||||||
|
.then(
|
||||||
|
if (onWeek == null) Modifier
|
||||||
|
else Modifier.semantics {
|
||||||
|
customActions = listOf(
|
||||||
|
CustomAccessibilityAction("Предыдущая неделя") { onWeek(-1); true },
|
||||||
|
CustomAccessibilityAction("Следующая неделя") { onWeek(1); true },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
repeat(7) { i ->
|
||||||
|
val date = monday.plusDays(i.toLong())
|
||||||
|
val isSelected = date == selected
|
||||||
|
val isToday = date == today()
|
||||||
|
val busy = hasLessons(date)
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.selectable(
|
||||||
|
selected = isSelected,
|
||||||
|
enabled = enabled(date),
|
||||||
|
role = Role.Tab,
|
||||||
|
onClick = { onSelect(date) },
|
||||||
|
)
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
.semantics(mergeDescendants = true) {
|
||||||
|
contentDescription = "${date.shortWeekday()} ${date.dayOfMonth}" +
|
||||||
|
(if (isToday) ", сегодня" else "") + (if (busy) "" else ", нет занятий")
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
date.shortWeekday(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (isToday) scheme.primary else scheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.background(if (isSelected) scheme.primary else Color.Transparent, CircleShape),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${date.dayOfMonth}",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = when {
|
||||||
|
isSelected -> scheme.onPrimary
|
||||||
|
isToday -> scheme.primary
|
||||||
|
busy -> scheme.onSurface
|
||||||
|
else -> scheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A School Planner style list card: the primary icon-and-title header, then full-width rows
|
||||||
|
* (see [CardRow]) separated by inset dividers, and an optional footer action.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ListCard(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.card, modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(bottom = 8.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 20.dp, end = 12.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.padding(6.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Heading(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized.copy(color = MaterialTheme.colorScheme.primary),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
trailing?.invoke()
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of a [ListCard]: a coloured avatar, a title with a supporting line and a trailing slot. */
|
||||||
|
@Composable
|
||||||
|
fun CardRow(
|
||||||
|
avatar: String,
|
||||||
|
color: SubjectColor,
|
||||||
|
title: String,
|
||||||
|
subtitle: String?,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (onClick != null) Modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick) else Modifier)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(44.dp).background(color.container, CircleShape).clearAndSetSemantics {},
|
||||||
|
) {
|
||||||
|
Text(avatar, style = MaterialTheme.typography.titleSmallEmphasized, color = color.content, maxLines = 1)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyLargeEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
subtitle?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailing?.let {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
it()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The divider between [CardRow]s: it starts under the text, as in School Planner's lists. */
|
||||||
|
@Composable
|
||||||
|
fun RowDivider() {
|
||||||
|
HorizontalDivider(Modifier.padding(start = 80.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Скопюк В.А." → "СВ", "23-БА" → "23": a short avatar label. */
|
||||||
|
fun avatarLabel(text: String): String {
|
||||||
|
val digits = text.takeWhile { it.isDigit() }
|
||||||
|
if (digits.length >= 2) return digits.take(2)
|
||||||
|
val parts = text.split(' ', '.', '-').filter { it.isNotEmpty() }
|
||||||
|
return parts.take(2).joinToString("") { it.first().uppercase() }.ifEmpty { "?" }
|
||||||
|
}
|
||||||
276
android/app/src/main/kotlin/ritm/schedule/ui/GroupsScreen.kt
Normal file
276
android/app/src/main/kotlin/ritm/schedule/ui/GroupsScreen.kt
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.rounded.AccountBalance
|
||||||
|
import androidx.compose.material.icons.rounded.CheckCircle
|
||||||
|
import androidx.compose.material.icons.rounded.History
|
||||||
|
import androidx.compose.material.icons.rounded.SearchOff
|
||||||
|
import ritm.schedule.ui.theme.card
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import androidx.compose.material.icons.rounded.Close
|
||||||
|
import androidx.compose.material.icons.rounded.Groups
|
||||||
|
import androidx.compose.material.icons.rounded.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import ritm.schedule.data.GroupResult
|
||||||
|
import ritm.schedule.data.attempt
|
||||||
|
import ritm.schedule.data.GroupsResponse
|
||||||
|
import ritm.schedule.data.SavedGroup
|
||||||
|
|
||||||
|
private val PLAIN_PERIOD = Regex("^(осенний|весенний) семестр$|^расписание из word$", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
private fun describe(faculty: String, course: String, form: String = "", period: String = "") =
|
||||||
|
listOfNotNull(
|
||||||
|
facultyShort(faculty),
|
||||||
|
course.ifEmpty { null },
|
||||||
|
form.removeSuffix(" форма").lowercase().ifEmpty { null },
|
||||||
|
period.takeIf { it.isNotEmpty() && !PLAIN_PERIOD.matches(it) }?.lowercase(),
|
||||||
|
).joinToString(" · ")
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun GroupsScreen(vm: MainViewModel, onOpened: () -> Unit, onBack: (() -> Unit)?) {
|
||||||
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
|
val current = settings?.current
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var faculty by rememberSaveable { mutableStateOf(current?.faculty.orEmpty()) }
|
||||||
|
var course by rememberSaveable { mutableStateOf("") }
|
||||||
|
var data by remember { mutableStateOf<GroupsResponse?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var opening by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(query, faculty, course) {
|
||||||
|
if (query.isNotEmpty()) delay(250)
|
||||||
|
attempt { vm.api.groups(query.trim(), faculty, course) }
|
||||||
|
.onSuccess { data = it; error = null }
|
||||||
|
.onFailure { error = it.message }
|
||||||
|
}
|
||||||
|
val searching = query.trim().length >= 2
|
||||||
|
|
||||||
|
fun open(result: GroupResult) {
|
||||||
|
opening = "${result.fileId}|${result.group}"
|
||||||
|
vm.open(result) { ok ->
|
||||||
|
opening = null
|
||||||
|
if (ok) onOpened()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
TopAppBar(
|
||||||
|
colors = appBarColors(),
|
||||||
|
title = { Text("Группы", style = MaterialTheme.typography.titleLargeEmphasized) },
|
||||||
|
navigationIcon = {
|
||||||
|
if (onBack != null) IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Назад")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
ContentColumn {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
if (current == null) item {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
Heading("Найдите свою группу", style = MaterialTheme.typography.headlineMediumEmphasized)
|
||||||
|
Text(
|
||||||
|
"Расписание откроется сразу и будет доступно без интернета.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { SearchField(query, { query = it }, "Номер группы, например 23-БА") }
|
||||||
|
error?.let { item { Banner(it, offline = true, onClose = null) } }
|
||||||
|
status.message?.let { message ->
|
||||||
|
item { Banner(message, offline = status.offline, onClose = vm::dismissMessage) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searching) {
|
||||||
|
val results = data?.results.orEmpty()
|
||||||
|
if (results.isNotEmpty()) item {
|
||||||
|
ListCard(Icons.Rounded.Search, "Найдено: ${results.size}") { GroupRows(results, opening, ::open) }
|
||||||
|
}
|
||||||
|
if (results.isEmpty() && data != null) item {
|
||||||
|
Empty(
|
||||||
|
"Группа не найдена",
|
||||||
|
"Группы «${query.trim()}» нет в каталоге ${data?.year}. Проверьте номер.",
|
||||||
|
icon = Icons.Rounded.SearchOff,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (current != null) item {
|
||||||
|
CurrentGroup(
|
||||||
|
current = current,
|
||||||
|
subgroups = timetable?.subgroups.orEmpty(),
|
||||||
|
subgroup = settings?.subgroup,
|
||||||
|
onSubgroup = vm::setSubgroup,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val recent = settings?.recent.orEmpty().filterNot { it.docId == current?.docId && it.group == current?.group }
|
||||||
|
if (recent.isNotEmpty()) item {
|
||||||
|
ListCard(Icons.Rounded.History, "Недавние") {
|
||||||
|
recent.forEachIndexed { i, g ->
|
||||||
|
if (i > 0) RowDivider()
|
||||||
|
CardRow(
|
||||||
|
avatar = avatarLabel(g.group),
|
||||||
|
color = subjectColor(avatarLabel(g.group)),
|
||||||
|
title = g.group,
|
||||||
|
subtitle = describe(g.faculty, g.course),
|
||||||
|
onClick = { vm.open(g, onOpened) },
|
||||||
|
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data?.let { d ->
|
||||||
|
item {
|
||||||
|
ListCard(Icons.Rounded.AccountBalance, "Каталог ${d.year}") {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("Факультет", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
d.facets.faculties.forEach { f ->
|
||||||
|
val on = faculty == f.name
|
||||||
|
FilterChip(
|
||||||
|
selected = on,
|
||||||
|
onClick = { faculty = if (on) "" else f.name; course = "" },
|
||||||
|
label = { Text(facultyShort(f.name) ?: f.name) },
|
||||||
|
leadingIcon = if (on) ({ Icon(Icons.Rounded.Check, null, Modifier.size(FilterChipDefaults.IconSize)) }) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (faculty.isNotEmpty()) {
|
||||||
|
Text("Курс", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
d.facets.courses.forEach { c ->
|
||||||
|
val on = course == c.name
|
||||||
|
FilterChip(
|
||||||
|
selected = on,
|
||||||
|
onClick = { course = if (on) "" else c.name },
|
||||||
|
label = { Text(c.name) },
|
||||||
|
leadingIcon = if (on) ({ Icon(Icons.Rounded.Check, null, Modifier.size(FilterChipDefaults.IconSize)) }) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else Text(
|
||||||
|
"Выберите факультет или введите номер группы. Всего в каталоге ${d.total} групп.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (faculty.isNotEmpty() && d.results.isNotEmpty()) {
|
||||||
|
RowDivider()
|
||||||
|
GroupRows(d.results, opening, ::open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data == null && error == null) item {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupRows(results: List<GroupResult>, opening: String?, onOpen: (GroupResult) -> Unit) {
|
||||||
|
results.forEachIndexed { index, r ->
|
||||||
|
if (index > 0) RowDivider()
|
||||||
|
val busy = opening == "${r.fileId}|${r.group}"
|
||||||
|
CardRow(
|
||||||
|
avatar = avatarLabel(r.group),
|
||||||
|
color = subjectColor(avatarLabel(r.group)),
|
||||||
|
title = r.group,
|
||||||
|
subtitle = describe(r.faculty, r.course, r.form, r.period),
|
||||||
|
onClick = { onOpen(r) },
|
||||||
|
enabled = opening == null,
|
||||||
|
trailing = {
|
||||||
|
if (busy) LoadingIndicator(Modifier.size(32.dp))
|
||||||
|
else Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CurrentGroup(current: SavedGroup, subgroups: List<String>, subgroup: String?, onSubgroup: (String?) -> Unit) {
|
||||||
|
ListCard(Icons.Rounded.CheckCircle, "Сейчас открыта") {
|
||||||
|
CardRow(
|
||||||
|
avatar = avatarLabel(current.group),
|
||||||
|
color = subjectColor(avatarLabel(current.group)),
|
||||||
|
title = current.group,
|
||||||
|
subtitle = describe(current.faculty, current.course),
|
||||||
|
onClick = null,
|
||||||
|
)
|
||||||
|
if (subgroups.isNotEmpty()) Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text("Подгруппа", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
ConnectedChoice(
|
||||||
|
options = listOf<Pair<String?, String>>(null to "Все") + subgroups.map { it to it.removePrefix("Подгруппа ") },
|
||||||
|
selected = subgroup,
|
||||||
|
onSelect = onSubgroup,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
390
android/app/src/main/kotlin/ritm/schedule/ui/HomeScreen.kt
Normal file
390
android/app/src/main/kotlin/ritm/schedule/ui/HomeScreen.kt
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import ritm.schedule.ui.theme.card
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
||||||
|
import androidx.compose.material.icons.rounded.CalendarViewWeek
|
||||||
|
import androidx.compose.material.icons.rounded.SwapHoriz
|
||||||
|
import androidx.compose.material.icons.rounded.Today
|
||||||
|
import androidx.compose.material.icons.rounded.TravelExplore
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.EventAvailable
|
||||||
|
import androidx.compose.material.icons.rounded.Insights
|
||||||
|
import androidx.compose.material.icons.rounded.Description
|
||||||
|
import androidx.compose.material.icons.rounded.Groups
|
||||||
|
import androidx.compose.material.icons.rounded.LocationOn
|
||||||
|
import androidx.compose.material.icons.rounded.Person
|
||||||
|
import androidx.compose.material.icons.rounded.Schedule
|
||||||
|
import androidx.compose.material.icons.rounded.ViewDay
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import ritm.schedule.data.Lesson
|
||||||
|
import ritm.schedule.data.Timetable
|
||||||
|
import ritm.schedule.data.longLabel
|
||||||
|
import ritm.schedule.data.minutesLabel
|
||||||
|
import ritm.schedule.data.monday
|
||||||
|
import ritm.schedule.data.nowTime
|
||||||
|
import ritm.schedule.data.parseTime
|
||||||
|
import ritm.schedule.data.plural
|
||||||
|
import ritm.schedule.data.today
|
||||||
|
import ritm.schedule.data.weekSpan
|
||||||
|
import ritm.schedule.data.weekdayName
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
/** One time slot of a day: parallel lessons of subgroups share it. */
|
||||||
|
data class Slot(val date: LocalDate, val start: LocalTime, val end: LocalTime, val lessons: List<Lesson>)
|
||||||
|
|
||||||
|
fun MainViewModel.slotsOn(t: Timetable, date: LocalDate, subgroup: String?): List<Slot> =
|
||||||
|
repository.lessonsOn(t, date, subgroup)
|
||||||
|
.groupBy { it.start }
|
||||||
|
.map { (start, lessons) -> Slot(date, parseTime(start), lessonEnd(lessons.first(), t), lessons) }
|
||||||
|
.sortedBy { it.start }
|
||||||
|
|
||||||
|
/** The current time, refreshed every half minute while the screen is shown. */
|
||||||
|
@Composable
|
||||||
|
fun rememberClock(): LocalTime {
|
||||||
|
var clock by remember { mutableStateOf(nowTime()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(30_000)
|
||||||
|
clock = nowTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clock
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
||||||
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
|
var details by remember { mutableStateOf<Lesson?>(null) }
|
||||||
|
val subgroup = settings?.subgroup
|
||||||
|
val clock = rememberClock()
|
||||||
|
val date = today()
|
||||||
|
|
||||||
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
TopAppBar(
|
||||||
|
colors = appBarColors(),
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Обзор", style = MaterialTheme.typography.titleLargeEmphasized)
|
||||||
|
(timetable?.group ?: settings?.current?.group)?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = { MenuButton(nav) },
|
||||||
|
)
|
||||||
|
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage) }
|
||||||
|
val t = timetable
|
||||||
|
when {
|
||||||
|
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
||||||
|
t == null -> Empty(
|
||||||
|
"Расписание не загружено",
|
||||||
|
"Выберите группу — обзор дня и недели появится здесь.",
|
||||||
|
icon = Icons.Rounded.Groups,
|
||||||
|
action = "Выбрать группу" to nav.openGroups,
|
||||||
|
)
|
||||||
|
t.semester == null -> Column(Modifier.padding(top = 8.dp)) {
|
||||||
|
Banner(t.note ?: "Даты для этого расписания не рассчитаны.", offline = false, onClose = null)
|
||||||
|
Empty("Обзор недоступен", "Строки Word по дням недели — во вкладке «Расписание».", icon = Icons.Rounded.Description, action = "Открыть" to { nav.openDay(date) })
|
||||||
|
}
|
||||||
|
else -> ContentColumn {
|
||||||
|
val todaySlots = vm.slotsOn(t, date, subgroup)
|
||||||
|
val next = (1..7).asSequence()
|
||||||
|
.map { date.plusDays(it.toLong()) }
|
||||||
|
.map { it to vm.slotsOn(t, it, subgroup) }
|
||||||
|
.firstOrNull { it.second.isNotEmpty() }
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item { QuickActions(nav, Modifier.bleed(16.dp)) }
|
||||||
|
item { WeekReport(vm, t, subgroup, date, clock) }
|
||||||
|
item { DayTitle("Сегодня", date) }
|
||||||
|
item { TodayCard(todaySlots, clock, onOpen = { details = it }, onDay = { nav.openDay(date) }) }
|
||||||
|
if (next != null) {
|
||||||
|
val (day, slots) = next
|
||||||
|
item { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) }
|
||||||
|
item {
|
||||||
|
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
|
||||||
|
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it }) } }
|
||||||
|
DayButton { nav.openDay(day) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details?.let { lesson ->
|
||||||
|
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson, timetable?.bells.orEmpty()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shortcuts under the app bar, as in School Planner's overview. */
|
||||||
|
@Composable
|
||||||
|
private fun QuickActions(nav: AppNav, modifier: Modifier = Modifier) {
|
||||||
|
val actions = listOf(
|
||||||
|
Triple("Сегодня", Icons.Rounded.Today) { nav.openDay(today()) },
|
||||||
|
Triple("Неделя", Icons.Rounded.CalendarViewWeek, nav.openWeek),
|
||||||
|
Triple("Предметы", Icons.AutoMirrored.Rounded.MenuBook, nav.openSubjects),
|
||||||
|
Triple("Кто где", Icons.Rounded.TravelExplore, nav.openSearch),
|
||||||
|
Triple("Группа", Icons.Rounded.SwapHoriz, nav.openGroups),
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
items(actions, key = { it.first }) { (label, icon, onClick) ->
|
||||||
|
FilledTonalButton(onClick = onClick, contentPadding = ButtonDefaults.ButtonWithIconContentPadding) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize))
|
||||||
|
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets a row scroll edge to edge inside a padded column. */
|
||||||
|
private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints ->
|
||||||
|
val extra = horizontal.roundToPx() * 2
|
||||||
|
val placeable = measurable.measure(
|
||||||
|
constraints.copy(minWidth = constraints.minWidth + extra, maxWidth = constraints.maxWidth + extra),
|
||||||
|
)
|
||||||
|
layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Прошло 12 из 18 пар" with a wavy progress line over the current week. */
|
||||||
|
@Composable
|
||||||
|
private fun WeekReport(vm: MainViewModel, t: Timetable, subgroup: String?, date: LocalDate, clock: LocalTime) {
|
||||||
|
val monday = date.monday()
|
||||||
|
val slots = (0..6L).flatMap { vm.slotsOn(t, monday.plusDays(it), subgroup) }
|
||||||
|
val done = slots.count { it.date < date || (it.date == date && !it.end.isAfter(clock)) }
|
||||||
|
val week = t.days.firstOrNull { it.date == date.toString() }?.week
|
||||||
|
SectionCard(Icons.Rounded.Insights, "Отчёт за неделю") {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
if (slots.isEmpty()) "На этой неделе занятий нет"
|
||||||
|
else "Прошло $done из ${slots.size} ${plural(slots.size, "пары", "пар", "пар")}",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
(week?.let { "Неделя ${it.n} · " } ?: "") + weekSpan(monday, monday.plusDays(6)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
week?.let { WeekMark(it, Modifier.padding(top = 2.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slots.isNotEmpty()) LinearWavyProgressIndicator(
|
||||||
|
progress = { done.toFloat() / slots.size },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayTitle(label: String, date: LocalDate) {
|
||||||
|
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
Heading(label, style = MaterialTheme.typography.headlineMediumEmphasized, modifier = Modifier.weight(1f))
|
||||||
|
Text(date.longLabel(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
|
||||||
|
val left = slots.filter { it.end.isAfter(clock) }
|
||||||
|
SectionCard(Icons.Rounded.Schedule, "Следующие занятия") {
|
||||||
|
when {
|
||||||
|
slots.isEmpty() -> Quiet("Сегодня занятий нет", "Отдохните — ближайшие пары ниже.")
|
||||||
|
left.isEmpty() -> Quiet(
|
||||||
|
"Пары на сегодня закончились",
|
||||||
|
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.",
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
val first = left.first()
|
||||||
|
first.lessons.forEach { FeaturedLesson(it, first, clock, onClick = { onOpen(it) }) }
|
||||||
|
if (left.size > 1) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
left.drop(1).forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { onOpen(it) }) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slots.isNotEmpty()) DayButton(onDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A titled tonal card: the dashboard's building block. */
|
||||||
|
@Composable
|
||||||
|
fun SectionCard(icon: ImageVector, title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.card,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.padding(6.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Heading(title, style = MaterialTheme.typography.titleMediumEmphasized.copy(color = MaterialTheme.colorScheme.primary))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Quiet(title: String, text: String) {
|
||||||
|
Column {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayButton(onClick: () -> Unit) {
|
||||||
|
TextButton(onClick = onClick, contentPadding = PaddingValues(horizontal = 8.dp)) {
|
||||||
|
Icon(Icons.Rounded.ViewDay, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Открыть день")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SubjectDot(subject: String, size: Dp = 12.dp, modifier: Modifier = Modifier) {
|
||||||
|
Box(modifier.size(size).background(subjectColor(subject).container, CircleShape))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Slot.timeLabel() = "$start → $end"
|
||||||
|
|
||||||
|
/** The next lesson in full: subject, time with a countdown, room and teacher. */
|
||||||
|
@Composable
|
||||||
|
private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick: () -> Unit) {
|
||||||
|
val running = !clock.isBefore(slot.start)
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick),
|
||||||
|
) {
|
||||||
|
SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(lesson.subject, style = MaterialTheme.typography.titleLargeEmphasized, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(slot.timeLabel(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
val minutes = Duration.between(clock, if (running) slot.end else slot.start).toMinutes()
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = if (running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = if (running) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (running) "Идёт · ещё ${minutesLabel(minutes)}" else "Через ${minutesLabel(minutes)}",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val show = LocalDisplay.current
|
||||||
|
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }
|
||||||
|
room?.let { HomeMeta(Icons.Rounded.LocationOn, it) }
|
||||||
|
if (show.teachers && lesson.teachers.isNotEmpty()) HomeMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Later lessons: a coloured dot, the subject and "13:40 → 15:05 · ауд. 254В". */
|
||||||
|
@Composable
|
||||||
|
fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
|
||||||
|
val show = LocalDisplay.current
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Подробнее", role = Role.Button, onClick = onClick) else Modifier),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
SubjectDot(lesson.subject, 10.dp)
|
||||||
|
Spacer(Modifier.width(18.dp))
|
||||||
|
Column {
|
||||||
|
Text(lesson.subject, style = MaterialTheme.typography.titleSmallEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
slot.timeLabel(),
|
||||||
|
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" },
|
||||||
|
lesson.type.takeIf { show.tags && it.isNotEmpty() }?.lowercase(),
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HomeMeta(icon: ImageVector, text: String) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ritm.schedule.app
|
||||||
|
import ritm.schedule.data.ApiException
|
||||||
|
import ritm.schedule.data.Display
|
||||||
|
import ritm.schedule.data.GroupResult
|
||||||
|
import ritm.schedule.data.SavedGroup
|
||||||
|
import ritm.schedule.data.ThemeMode
|
||||||
|
import ritm.schedule.data.Timetable
|
||||||
|
import ritm.schedule.data.UserSettings
|
||||||
|
import ritm.schedule.work.SyncWorker
|
||||||
|
|
||||||
|
data class Status(val loading: Boolean = false, val message: String? = null, val offline: Boolean = false)
|
||||||
|
|
||||||
|
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val app = application.app
|
||||||
|
val api = app.repository.api
|
||||||
|
val repository = app.repository
|
||||||
|
|
||||||
|
/** null until DataStore answers, so the first frame never shows the wrong screen. */
|
||||||
|
val settings: StateFlow<UserSettings?> =
|
||||||
|
app.settings.flow.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||||
|
val timetable: StateFlow<Timetable?> = app.repository.timetable
|
||||||
|
|
||||||
|
private val _status = MutableStateFlow(Status())
|
||||||
|
val status = _status.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (app.settings.now().current != null) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() = viewModelScope.launch { perform { repository.refresh() } }
|
||||||
|
|
||||||
|
/** done(true) when the timetable loaded; on failure the status carries the message. */
|
||||||
|
fun open(result: GroupResult, done: (Boolean) -> Unit) = viewModelScope.launch {
|
||||||
|
done(perform { repository.open(result) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(saved: SavedGroup, done: () -> Unit) = viewModelScope.launch {
|
||||||
|
// A saved group opens from the cache too when the network is down.
|
||||||
|
perform { repository.open(saved) }
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun perform(block: suspend () -> Result<Timetable>): Boolean {
|
||||||
|
_status.value = _status.value.copy(loading = true)
|
||||||
|
val result = block()
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
_status.value = Status(
|
||||||
|
loading = false,
|
||||||
|
message = error?.message,
|
||||||
|
offline = (error as? ApiException)?.offline == true,
|
||||||
|
)
|
||||||
|
SyncWorker.afterChange(getApplication())
|
||||||
|
return result.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissMessage() {
|
||||||
|
_status.value = _status.value.copy(message = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubgroup(label: String?) = viewModelScope.launch {
|
||||||
|
app.settings.setSubgroup(label)
|
||||||
|
SyncWorker.afterChange(getApplication())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTheme(mode: ThemeMode) = viewModelScope.launch { app.settings.setTheme(mode) }
|
||||||
|
fun setDynamicColor(on: Boolean) = viewModelScope.launch { app.settings.setDynamicColor(on) }
|
||||||
|
fun setDisplay(display: Display) = viewModelScope.launch { app.settings.setDisplay(display) }
|
||||||
|
|
||||||
|
fun setReminders(on: Boolean) = viewModelScope.launch {
|
||||||
|
app.settings.setReminders(on)
|
||||||
|
SyncWorker.afterChange(getApplication())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setReminderMinutes(minutes: Int) = viewModelScope.launch {
|
||||||
|
app.settings.setReminderMinutes(minutes)
|
||||||
|
SyncWorker.afterChange(getApplication())
|
||||||
|
}
|
||||||
|
}
|
||||||
31
android/app/src/main/kotlin/ritm/schedule/ui/Nav.kt
Normal file
31
android/app/src/main/kotlin/ritm/schedule/ui/Nav.kt
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Menu
|
||||||
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.material.icons.rounded.SwapHoriz
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** Ways a screen can send the student elsewhere in the app. */
|
||||||
|
class AppNav(
|
||||||
|
val openGroups: () -> Unit,
|
||||||
|
val openSettings: () -> Unit,
|
||||||
|
val openDay: (LocalDate) -> Unit,
|
||||||
|
val openWeek: () -> Unit,
|
||||||
|
val openSubjects: () -> Unit,
|
||||||
|
val openSearch: () -> Unit,
|
||||||
|
/** Opens the side menu on phones; null when the rail is always on screen. */
|
||||||
|
val openMenu: (() -> Unit)?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The hamburger of a top-level screen's app bar. */
|
||||||
|
@Composable
|
||||||
|
fun MenuButton(nav: AppNav) {
|
||||||
|
nav.openMenu?.let { open ->
|
||||||
|
IconButton(onClick = open) { Icon(Icons.Rounded.Menu, contentDescription = "Открыть меню") }
|
||||||
|
}
|
||||||
|
}
|
||||||
631
android/app/src/main/kotlin/ritm/schedule/ui/ScheduleScreen.kt
Normal file
631
android/app/src/main/kotlin/ritm/schedule/ui/ScheduleScreen.kt
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kotlin.math.abs
|
||||||
|
import androidx.compose.material.icons.rounded.Weekend
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material.icons.rounded.Today
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
import ritm.schedule.ui.theme.card
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.CloudOff
|
||||||
|
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||||
|
import androidx.compose.material.icons.rounded.EventBusy
|
||||||
|
import androidx.compose.material.icons.rounded.EventRepeat
|
||||||
|
import androidx.compose.material.icons.rounded.Groups
|
||||||
|
import androidx.compose.material.icons.rounded.LocationOn
|
||||||
|
import androidx.compose.material.icons.rounded.Person
|
||||||
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
|
import androidx.compose.material.icons.rounded.CalendarViewWeek
|
||||||
|
import androidx.compose.material.icons.rounded.MoreVert
|
||||||
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.material.icons.rounded.SwapHoriz
|
||||||
|
import androidx.compose.material.icons.rounded.ViewDay
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.material.icons.rounded.Schedule
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ritm.schedule.data.Bell
|
||||||
|
import ritm.schedule.data.Lesson
|
||||||
|
import ritm.schedule.data.Timetable
|
||||||
|
import ritm.schedule.data.dayName
|
||||||
|
import ritm.schedule.data.longLabel
|
||||||
|
import ritm.schedule.data.minutesLabel
|
||||||
|
import ritm.schedule.data.monday
|
||||||
|
import ritm.schedule.data.nowTime
|
||||||
|
import ritm.schedule.data.parseDate
|
||||||
|
import ritm.schedule.data.parseTime
|
||||||
|
import ritm.schedule.data.today
|
||||||
|
import ritm.schedule.data.weekdayName
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ScheduleScreen(
|
||||||
|
vm: MainViewModel,
|
||||||
|
nav: AppNav,
|
||||||
|
weekView: Boolean,
|
||||||
|
onWeekView: (Boolean) -> Unit,
|
||||||
|
focus: LocalDate?,
|
||||||
|
onFocusHandled: () -> Unit,
|
||||||
|
) {
|
||||||
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
|
var details by remember { mutableStateOf<Lesson?>(null) }
|
||||||
|
val subgroup = settings?.subgroup
|
||||||
|
val t = timetable
|
||||||
|
|
||||||
|
if (t?.semester != null) {
|
||||||
|
Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { details = it })
|
||||||
|
} else Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
colors = appBarColors(),
|
||||||
|
navigationIcon = { MenuButton(nav) },
|
||||||
|
title = { Text(t?.group ?: settings?.current?.group ?: "Расписание", style = MaterialTheme.typography.titleLargeEmphasized) },
|
||||||
|
actions = { ScheduleMenu(vm, nav) },
|
||||||
|
)
|
||||||
|
status.message?.let { Banner(it, offline = status.offline, onClose = vm::dismissMessage) }
|
||||||
|
when {
|
||||||
|
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
||||||
|
t == null -> Empty(
|
||||||
|
"Расписание не загружено",
|
||||||
|
"Проверьте подключение или выберите группу заново.",
|
||||||
|
icon = Icons.Rounded.CloudOff,
|
||||||
|
action = "Выбрать группу" to nav.openGroups,
|
||||||
|
)
|
||||||
|
else -> WordRows(t, subgroup, onOpen = { details = it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details?.let { lesson ->
|
||||||
|
ModalBottomSheet(onDismissRequest = { details = null }) {
|
||||||
|
LessonDetails(lesson, timetable?.bells.orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh is the only schedule action left in the app bar; groups and settings live in the menu. */
|
||||||
|
@Composable
|
||||||
|
private fun ScheduleMenu(vm: MainViewModel, nav: AppNav) {
|
||||||
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
|
IconButton(onClick = { vm.refresh() }, enabled = !status.loading) {
|
||||||
|
Icon(Icons.Rounded.Refresh, contentDescription = "Обновить расписание")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MONTH_SHORT = DateTimeFormatter.ofPattern("LLL", Locale.forLanguageTag("ru"))
|
||||||
|
|
||||||
|
/** "Окт. · Неделя 7" — the School Planner style title of the shown week. */
|
||||||
|
internal fun weekTitle(date: LocalDate, n: Int?): String {
|
||||||
|
val month = date.format(MONTH_SHORT).replaceFirstChar { it.titlecase() }
|
||||||
|
return if (n != null) "$month · Неделя $n" else month
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dated timetable: a tinted header (app bar and calm day strip) over the lighter
|
||||||
|
* content, as in School Planner. Days page horizontally; the week view is a time grid.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Planner(
|
||||||
|
vm: MainViewModel,
|
||||||
|
nav: AppNav,
|
||||||
|
t: Timetable,
|
||||||
|
subgroup: String?,
|
||||||
|
weekView: Boolean,
|
||||||
|
onWeekView: (Boolean) -> Unit,
|
||||||
|
focus: LocalDate?,
|
||||||
|
onFocusHandled: () -> Unit,
|
||||||
|
onOpen: (Lesson) -> Unit,
|
||||||
|
) {
|
||||||
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
|
val dates = remember(t) { t.days.map { parseDate(it.date) } }
|
||||||
|
// On Sunday the next study day is more useful than an empty page.
|
||||||
|
val start = remember(t) {
|
||||||
|
val target = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
|
||||||
|
dates.indexOf(target).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
val pager = rememberPagerState(initialPage = start) { dates.size }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val date = dates.getOrElse(pager.currentPage) { today() }
|
||||||
|
var gridMonday by rememberSaveable { mutableLongStateOf(date.monday().toEpochDay()) }
|
||||||
|
val grid = LocalDate.ofEpochDay(gridMonday)
|
||||||
|
val mondays = remember(dates) { dates.map { it.monday() }.distinct() }
|
||||||
|
fun weekOf(monday: LocalDate) = t.days.firstOrNull { it.date == monday.toString() }?.week
|
||||||
|
|
||||||
|
fun go(target: LocalDate) {
|
||||||
|
val index = dates.indexOf(target)
|
||||||
|
if (index >= 0) scope.launch { pager.animateScrollToPage(index) }
|
||||||
|
}
|
||||||
|
fun week(step: Int) {
|
||||||
|
if (weekView) {
|
||||||
|
val next = grid.plusDays(7L * step)
|
||||||
|
if (dates.contains(next)) gridMonday = next.toEpochDay()
|
||||||
|
} else {
|
||||||
|
val next = date.plusDays(7L * step)
|
||||||
|
go(if (dates.contains(next)) next else if (step > 0) dates.last() else dates.first())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A day or week opened from elsewhere (overview shortcuts, week grid headers).
|
||||||
|
LaunchedEffect(focus) {
|
||||||
|
if (focus != null) {
|
||||||
|
dates.indexOf(focus).takeIf { it >= 0 }?.let { pager.requestScrollToPage(it) }
|
||||||
|
gridMonday = focus.monday().toEpochDay()
|
||||||
|
onFocusHandled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The strip is a pager of weeks: it follows the day pager, and swiping it moves the days.
|
||||||
|
val strip = rememberPagerState(initialPage = mondays.indexOf(date.monday()).coerceAtLeast(0)) { mondays.size }
|
||||||
|
LaunchedEffect(date.monday()) {
|
||||||
|
val index = mondays.indexOf(date.monday())
|
||||||
|
if (index >= 0 && index != strip.currentPage) strip.animateScrollToPage(index)
|
||||||
|
}
|
||||||
|
LaunchedEffect(strip) {
|
||||||
|
snapshotFlow { strip.settledPage }.collect { page ->
|
||||||
|
val shown = dates.getOrElse(pager.currentPage) { today() }
|
||||||
|
val monday = mondays[page]
|
||||||
|
if (monday != shown.monday()) {
|
||||||
|
// The same weekday in the swiped-to week, or the nearest loaded day.
|
||||||
|
val target = monday.plusDays(shown.dayOfWeek.value - 1L)
|
||||||
|
go(dates.minBy { abs(it.toEpochDay() - target.toEpochDay()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val shownMonday = if (weekView) grid else date.monday()
|
||||||
|
val week = weekOf(shownMonday)
|
||||||
|
val tint = weekTint(week)
|
||||||
|
val onToday = if (weekView) grid == today().monday() else date == today()
|
||||||
|
val doc = t.document
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Column(Modifier.background(tint)) {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
scrolledContainerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
navigationIcon = { MenuButton(nav) },
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(weekTitle(shownMonday.plusDays(3), week?.n), style = MaterialTheme.typography.titleLargeEmphasized, maxLines = 1)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
listOfNotNull(t.group, subgroup?.lowercase()?.replace("подгруппа", "подгр.")).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
week?.let {
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
WeekMark(it, short = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!onToday) IconButton(onClick = {
|
||||||
|
if (weekView) gridMonday = today().monday().toEpochDay() else go(today())
|
||||||
|
}) { Icon(Icons.Rounded.Today, contentDescription = "Сегодня") }
|
||||||
|
IconButton(onClick = {
|
||||||
|
if (!weekView) gridMonday = date.monday().toEpochDay()
|
||||||
|
onWeekView(!weekView)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
if (weekView) Icons.Rounded.ViewDay else Icons.Rounded.CalendarViewWeek,
|
||||||
|
contentDescription = if (weekView) "Показать день" else "Показать неделю",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ScheduleMenu(vm, nav)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!weekView) HorizontalPager(state = strip, beyondViewportPageCount = 1) { page ->
|
||||||
|
val monday = mondays[page]
|
||||||
|
Box(Modifier.fillMaxWidth().background(weekTintOf(weekOf(monday))), contentAlignment = Alignment.Center) {
|
||||||
|
DayStrip(
|
||||||
|
monday = monday,
|
||||||
|
selected = date,
|
||||||
|
hasLessons = { d -> vm.repository.lessonsOn(t, d, subgroup).isNotEmpty() },
|
||||||
|
enabled = { d -> dates.contains(d) },
|
||||||
|
onSelect = ::go,
|
||||||
|
onWeek = ::week,
|
||||||
|
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status.message?.let {
|
||||||
|
Box(Modifier.background(MaterialTheme.colorScheme.page)) { Banner(it, offline = status.offline, onClose = vm::dismissMessage) }
|
||||||
|
}
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (weekView) {
|
||||||
|
// The week grid pages through weeks under the finger as well.
|
||||||
|
val weeks = rememberPagerState(initialPage = mondays.indexOf(grid).coerceAtLeast(0)) { mondays.size }
|
||||||
|
LaunchedEffect(gridMonday) {
|
||||||
|
val index = mondays.indexOf(grid)
|
||||||
|
if (index >= 0 && index != weeks.currentPage) weeks.animateScrollToPage(index)
|
||||||
|
}
|
||||||
|
LaunchedEffect(weeks) {
|
||||||
|
snapshotFlow { weeks.settledPage }.collect { gridMonday = mondays[it].toEpochDay() }
|
||||||
|
}
|
||||||
|
HorizontalPager(state = weeks, verticalAlignment = Alignment.Top) { page ->
|
||||||
|
val monday = mondays[page]
|
||||||
|
WeekGrid(
|
||||||
|
vm, t, subgroup, monday,
|
||||||
|
onOpen = onOpen,
|
||||||
|
onWeek = ::week,
|
||||||
|
headerColor = weekTintOf(weekOf(monday)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else PullToRefreshBox(isRefreshing = status.loading, onRefresh = { vm.refresh() }) {
|
||||||
|
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
|
||||||
|
val pageDate = dates[page]
|
||||||
|
DayPage(
|
||||||
|
date = pageDate,
|
||||||
|
slots = vm.slotsOn(t, pageDate, subgroup),
|
||||||
|
onOpen = onOpen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson) -> Unit) {
|
||||||
|
val clock = rememberClock()
|
||||||
|
val isToday = date == today()
|
||||||
|
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
|
||||||
|
ContentColumn {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + navBarPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
if (slots.isEmpty()) item {
|
||||||
|
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
|
||||||
|
}
|
||||||
|
slots.forEach { slot ->
|
||||||
|
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
|
||||||
|
val status = when {
|
||||||
|
running -> "Идёт · ещё ${minutesLabel(Duration.between(clock, slot.end).toMinutes())}"
|
||||||
|
slot == next && Duration.between(clock, slot.start).toMinutes() <= 120 ->
|
||||||
|
"Через ${minutesLabel(Duration.between(clock, slot.start).toMinutes())}"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
items(slot.lessons, key = { "${slot.start}|${it.key}" }) { lesson ->
|
||||||
|
LessonCard(
|
||||||
|
lesson,
|
||||||
|
time = "${slot.start} → ${slot.end}",
|
||||||
|
pair = lesson.pair,
|
||||||
|
status = status,
|
||||||
|
running = running,
|
||||||
|
onClick = { onOpen(lesson) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lesson as a soft card in its subject's colour, like School Planner's day list. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun LessonCard(
|
||||||
|
lesson: Lesson,
|
||||||
|
time: String?,
|
||||||
|
pair: Int? = null,
|
||||||
|
status: String? = null,
|
||||||
|
running: Boolean = false,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
val color = subjectColor(lesson.subject)
|
||||||
|
val show = LocalDisplay.current
|
||||||
|
val content: @Composable () -> Unit = {
|
||||||
|
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
|
||||||
|
status?.let {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = if (running) color.onSoft else color.onSoft.copy(alpha = 0.12f),
|
||||||
|
contentColor = if (running) color.soft else color.onSoft,
|
||||||
|
) {
|
||||||
|
Text(it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time?.let { Text(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
|
||||||
|
else if (show.rooms && lesson.rooms.isNotEmpty()) Meta(
|
||||||
|
Icons.Rounded.LocationOn,
|
||||||
|
lesson.rooms.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "",
|
||||||
|
)
|
||||||
|
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
||||||
|
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
|
||||||
|
}
|
||||||
|
val tags = if (!show.tags) emptyList() else listOfNotNull(
|
||||||
|
lesson.type.ifEmpty { null },
|
||||||
|
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
|
||||||
|
if (lesson.groups.size > 1) "общее занятие" else null,
|
||||||
|
)
|
||||||
|
if (tags.isNotEmpty() || lesson.uncertain) FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
tags.forEach { SoftTag(it, color) }
|
||||||
|
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
|
||||||
|
}
|
||||||
|
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val border = if (running) BorderStroke(2.dp, color.onSoft) else null
|
||||||
|
if (onClick != null) Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = color.soft,
|
||||||
|
contentColor = color.onSoft,
|
||||||
|
border = border,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
content = content,
|
||||||
|
) else Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = color.soft,
|
||||||
|
contentColor = color.onSoft,
|
||||||
|
border = border,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SoftTag(text: String, color: SubjectColor) {
|
||||||
|
Surface(shape = MaterialTheme.shapes.small, color = color.onSoft.copy(alpha = 0.12f), contentColor = color.onSoft) {
|
||||||
|
Text(text.replaceFirstChar { it.titlecase() }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Meta(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TypeTag(type: String) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val (bg, fg) = when (type) {
|
||||||
|
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||||
|
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||||
|
"Экзамен", "Зачёт" -> scheme.errorContainer to scheme.onErrorContainer
|
||||||
|
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
||||||
|
}
|
||||||
|
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
|
||||||
|
Text(type.ifEmpty { "Занятие" }, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Tag(text: String, warning: Boolean = false) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = if (warning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
contentColor = if (warning) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
) {
|
||||||
|
Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LessonDetails(lesson: Lesson, bells: List<Bell>) {
|
||||||
|
val bell = bells.firstOrNull { it.start == lesson.start }
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
TypeTag(lesson.type)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SubjectDot(lesson.subject, 16.dp)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Heading(lesson.subject, style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||||
|
}
|
||||||
|
Detail(
|
||||||
|
Icons.Rounded.Schedule,
|
||||||
|
bell?.let { "${it.pair} пара" } ?: "Время",
|
||||||
|
lesson.start + (lesson.end?.let { " — $it" } ?: ""),
|
||||||
|
bell?.halves?.takeIf { it.size > 1 }?.joinToString(", ") { it.joinToString("–") },
|
||||||
|
)
|
||||||
|
Detail(Icons.Rounded.Person, "Преподаватель", lesson.teachers.joinToString(", ").ifEmpty { "Не указан" })
|
||||||
|
Detail(
|
||||||
|
Icons.Rounded.LocationOn,
|
||||||
|
"Аудитория",
|
||||||
|
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
|
||||||
|
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
|
||||||
|
)
|
||||||
|
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
|
||||||
|
if (lesson.uncertain) Banner(
|
||||||
|
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
|
||||||
|
offline = false,
|
||||||
|
onClose = null,
|
||||||
|
)
|
||||||
|
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||||
|
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||||
|
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Detail(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String, extra: String? = null) {
|
||||||
|
Row {
|
||||||
|
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
|
||||||
|
extra?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When the week table has no semester for this schedule: the Word rows by weekday. */
|
||||||
|
@Composable
|
||||||
|
private fun WordRows(t: Timetable, subgroup: String?, onOpen: (Lesson) -> Unit) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + navBarPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item { Banner(t.note ?: "Даты не рассчитаны: показаны строки Word.", offline = false, onClose = null) }
|
||||||
|
for (day in t.weekly) {
|
||||||
|
item(key = "h${day.day}") {
|
||||||
|
Heading(dayName(day.day), style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||||
|
}
|
||||||
|
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
||||||
|
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
|
||||||
|
LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Banner(text: String, offline: Boolean, onClose: (() -> Unit)?) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = if (offline) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = if (offline) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(if (offline) Icons.Rounded.CloudOff else Icons.Rounded.ErrorOutline, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f).padding(vertical = 8.dp))
|
||||||
|
if (onClose != null) TextButton(onClick = onClose) { Text("OK") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state in the RideBus style: an icon on a seven-sided cookie shape, a title,
|
||||||
|
* a calm explanation and an optional tonal action.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Empty(
|
||||||
|
title: String,
|
||||||
|
text: String,
|
||||||
|
icon: ImageVector = Icons.Rounded.EventBusy,
|
||||||
|
action: Pair<String, () -> Unit>? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(112.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer, MaterialShapes.Cookie7Sided.toShape()),
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(title, style = MaterialTheme.typography.titleLargeEmphasized, textAlign = TextAlign.Center)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
action?.let { (label, onClick) ->
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FACULTIES = mapOf(
|
||||||
|
"Финансово-экономический факультет" to "ФЭФ",
|
||||||
|
"Факультет информационных технологий" to "ФИТ",
|
||||||
|
"Факультет компьютерных наук и электроники" to "ФКНЭ",
|
||||||
|
"Гуманитарный факультет" to "ГФ",
|
||||||
|
"Инженерно-строительный факультет" to "ИСФ",
|
||||||
|
"Механико-технологический факультет" to "МТФ",
|
||||||
|
"Юридический факультет" to "ЮФ",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun facultyShort(name: String): String? = name.takeIf { it.isNotEmpty() }?.let { FACULTIES[it] ?: it }
|
||||||
427
android/app/src/main/kotlin/ritm/schedule/ui/SearchScreen.kt
Normal file
427
android/app/src/main/kotlin/ritm/schedule/ui/SearchScreen.kt
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.material.icons.rounded.PersonSearch
|
||||||
|
import androidx.compose.material.icons.rounded.Schedule
|
||||||
|
import androidx.compose.material.icons.rounded.Today
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import androidx.compose.material.icons.rounded.SearchOff
|
||||||
|
import androidx.compose.material.icons.rounded.EventAvailable
|
||||||
|
import ritm.schedule.ui.theme.card
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.rounded.Close
|
||||||
|
import androidx.compose.material.icons.rounded.MeetingRoom
|
||||||
|
import androidx.compose.material.icons.rounded.Person
|
||||||
|
import androidx.compose.material.icons.rounded.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import ritm.schedule.data.Bell
|
||||||
|
import ritm.schedule.data.attempt
|
||||||
|
import ritm.schedule.data.Coverage
|
||||||
|
import ritm.schedule.data.DirectoryDay
|
||||||
|
import ritm.schedule.data.DirectorySearch
|
||||||
|
import ritm.schedule.data.FreeRooms
|
||||||
|
import ritm.schedule.data.Lesson
|
||||||
|
import ritm.schedule.data.longLabel
|
||||||
|
import ritm.schedule.data.monday
|
||||||
|
import ritm.schedule.data.nowTime
|
||||||
|
import ritm.schedule.data.parseTime
|
||||||
|
import ritm.schedule.data.today
|
||||||
|
import ritm.schedule.data.weekdayName
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
private sealed interface Target {
|
||||||
|
data class Teacher(val name: String) : Target
|
||||||
|
data class Room(val room: String) : Target
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDate(): LocalDate = if (today().dayOfWeek.value == 7) today().plusDays(1) else today()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
||||||
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
|
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var target by remember { mutableStateOf<Target?>(null) }
|
||||||
|
var date by remember { mutableStateOf(startDate()) }
|
||||||
|
var bells by remember { mutableStateOf(timetable?.bells.orEmpty()) }
|
||||||
|
var pair by remember { mutableIntStateOf(1) }
|
||||||
|
var hits by remember { mutableStateOf<DirectorySearch?>(null) }
|
||||||
|
var day by remember { mutableStateOf<DirectoryDay?>(null) }
|
||||||
|
var free by remember { mutableStateOf<FreeRooms?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var loading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (bells.isEmpty()) attempt { vm.api.calendar() }.onSuccess { bells = it.bells }
|
||||||
|
val now = nowTime()
|
||||||
|
pair = bells.firstOrNull { parseTime(it.end).isAfter(now) }?.pair ?: bells.firstOrNull()?.pair ?: 1
|
||||||
|
}
|
||||||
|
LaunchedEffect(query) {
|
||||||
|
if (query.trim().length < 2) { hits = null; return@LaunchedEffect }
|
||||||
|
delay(250)
|
||||||
|
attempt { vm.api.searchDirectory(query.trim()) }.onSuccess { hits = it; error = null }.onFailure { error = it.message }
|
||||||
|
}
|
||||||
|
LaunchedEffect(target, date) {
|
||||||
|
val t = target ?: return@LaunchedEffect
|
||||||
|
loading = true
|
||||||
|
attempt {
|
||||||
|
when (t) {
|
||||||
|
is Target.Teacher -> vm.api.teacherDay(t.name, date.toString())
|
||||||
|
is Target.Room -> vm.api.roomDay(t.room, date.toString())
|
||||||
|
}
|
||||||
|
}.onSuccess { day = it; error = null }.onFailure { error = it.message }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
LaunchedEffect(tab, date, pair) {
|
||||||
|
if (tab != 1) return@LaunchedEffect
|
||||||
|
loading = true
|
||||||
|
attempt { vm.api.freeRooms(date.toString(), pair) }
|
||||||
|
.onSuccess { free = it; error = null }.onFailure { error = it.message }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back from a teacher's or room's day returns to the search results.
|
||||||
|
BackHandler(enabled = target != null) { target = null; day = null }
|
||||||
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val dated = target != null || tab == 1
|
||||||
|
val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week
|
||||||
|
// Undated search tints its header once results scroll under it; dated views carry the week's colour.
|
||||||
|
val scrolledTint by animateColorAsState(
|
||||||
|
if (scroll.state.overlappedFraction > 0.01f) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.page,
|
||||||
|
MaterialTheme.motionScheme.defaultEffectsSpec(),
|
||||||
|
label = "search-header",
|
||||||
|
)
|
||||||
|
val weekColor = weekTint(week)
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
Column(Modifier.background(if (dated) weekColor else scrolledTint)) {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent),
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
when (val t = target) {
|
||||||
|
is Target.Teacher -> t.name
|
||||||
|
is Target.Room -> "Аудитория ${t.room}"
|
||||||
|
null -> "Поиск"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (dated) Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
weekTitle(date.monday().plusDays(3), week?.n),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
week?.let {
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
WeekMark(it, short = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
if (target != null) IconButton(onClick = { target = null; day = null }) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Назад к поиску")
|
||||||
|
} else MenuButton(nav)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (dated && date != startDate()) IconButton(onClick = { date = startDate() }) {
|
||||||
|
Icon(Icons.Rounded.Today, contentDescription = "Сегодня")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (target == null) ConnectedChoice(
|
||||||
|
options = listOf(0 to "Кто где", 1 to "Свободные"),
|
||||||
|
selected = tab,
|
||||||
|
onSelect = { tab = it },
|
||||||
|
modifier = Modifier.widthIn(max = MAX_CONTENT).fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
|
||||||
|
.align(Alignment.CenterHorizontally),
|
||||||
|
)
|
||||||
|
if (dated) SearchWeekStrip(timetable, date) { date = it }
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxSize().background(if (target != null) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.page)) {
|
||||||
|
ContentColumn {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp + navBarPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
error?.let { item { Banner(it, offline = true, onClose = null) } }
|
||||||
|
|
||||||
|
if (tab == 0 && target == null) {
|
||||||
|
item { SearchField(query, { query = it }, "Фамилия или номер аудитории") }
|
||||||
|
val h = hits
|
||||||
|
if (h == null) item {
|
||||||
|
Empty(
|
||||||
|
"Кто где",
|
||||||
|
"Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
|
||||||
|
icon = Icons.Rounded.PersonSearch,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (h.teachers.isNotEmpty()) item {
|
||||||
|
ListCard(Icons.Rounded.Person, "Преподаватели") {
|
||||||
|
h.teachers.forEachIndexed { index, t ->
|
||||||
|
if (index > 0) RowDivider()
|
||||||
|
CardRow(
|
||||||
|
avatar = avatarLabel(t.name),
|
||||||
|
color = subjectColor(t.name),
|
||||||
|
title = t.name,
|
||||||
|
subtitle = t.faculties.joinToString(", ") { facultyShort(it) ?: it },
|
||||||
|
onClick = { target = Target.Teacher(t.name) },
|
||||||
|
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (h.rooms.isNotEmpty()) item {
|
||||||
|
ListCard(Icons.Rounded.MeetingRoom, "Аудитории") {
|
||||||
|
Box(Modifier.padding(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 12.dp)) {
|
||||||
|
RoomChips(h.rooms.map { it.room }) { target = Target.Room(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (h.teachers.isEmpty() && h.rooms.isEmpty()) item {
|
||||||
|
Empty("Никого не нашли", "Ни преподавателя, ни аудитории с таким названием в расписаниях каталога.", icon = Icons.Rounded.SearchOff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target != null) {
|
||||||
|
val d = day
|
||||||
|
if (loading && d == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } }
|
||||||
|
if (d != null) {
|
||||||
|
if (d.lessons.isEmpty()) item { Empty("Занятий нет", "По расписаниям каталога в этот день здесь пусто.", icon = Icons.Rounded.EventAvailable) }
|
||||||
|
items(d.lessons, key = { "${it.start}|${it.subject}|${it.rooms}" }) { l ->
|
||||||
|
LessonCard(
|
||||||
|
Lesson(
|
||||||
|
key = l.start + l.subject,
|
||||||
|
start = l.start,
|
||||||
|
end = l.end,
|
||||||
|
subject = l.subject,
|
||||||
|
type = l.type,
|
||||||
|
teachers = if (target is Target.Room) l.teachers else emptyList(),
|
||||||
|
rooms = if (target is Target.Teacher) l.rooms else emptyList(),
|
||||||
|
roomMoved = l.moved,
|
||||||
|
distance = l.distance,
|
||||||
|
condition = l.condition,
|
||||||
|
groups = l.groups,
|
||||||
|
subgroup = l.groups.joinToString(", "),
|
||||||
|
uncertain = l.uncertain,
|
||||||
|
),
|
||||||
|
time = l.start + (l.end?.let { " → $it" } ?: ""),
|
||||||
|
pair = bells.firstOrNull { it.start == l.start }?.pair,
|
||||||
|
onClick = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { CoverageNote(d.coverage, free = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab == 1 && target == null) {
|
||||||
|
item {
|
||||||
|
ListCard(Icons.Rounded.Schedule, "Пара") {
|
||||||
|
Box(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)) { PairPicker(bells, pair) { pair = it } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (loading && free == null) item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { LoadingIndicator() } }
|
||||||
|
free?.let { f ->
|
||||||
|
item {
|
||||||
|
ListCard(Icons.Rounded.MeetingRoom, "Свободно ${f.freeCount} из ${f.knownRooms}") {
|
||||||
|
Text(
|
||||||
|
"${f.bell.pair} пара, ${f.bell.start}–${f.bell.end} · занято ${f.busyCount}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 64.dp, end = 20.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
f.free.forEachIndexed { index, g ->
|
||||||
|
if (index > 0) HorizontalDivider(
|
||||||
|
Modifier.padding(horizontal = 20.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (g.letter.isEmpty()) "Номера без буквы" else "Номера с «${g.letter}»",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
RoomChips(g.rooms) { room -> tab = 0; target = Target.Room(room) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { CoverageNote(f.coverage, free = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The calm day strip as a pager of weeks around today; each page wears its week's colour. */
|
||||||
|
@Composable
|
||||||
|
private fun SearchWeekStrip(timetable: ritm.schedule.data.Timetable?, date: LocalDate, onDate: (LocalDate) -> Unit) {
|
||||||
|
val base = remember { startDate().monday() }
|
||||||
|
fun mondayAt(page: Int): LocalDate = base.plusWeeks((page - SPAN).toLong())
|
||||||
|
val pager = rememberPagerState(initialPage = SPAN + ChronoUnit.WEEKS.between(base, date.monday()).toInt()) { SPAN * 2 + 1 }
|
||||||
|
val current by rememberUpdatedState(date)
|
||||||
|
LaunchedEffect(date.monday()) {
|
||||||
|
val index = SPAN + ChronoUnit.WEEKS.between(base, date.monday()).toInt()
|
||||||
|
if (index != pager.currentPage && index in 0 until pager.pageCount) pager.animateScrollToPage(index)
|
||||||
|
}
|
||||||
|
LaunchedEffect(pager) {
|
||||||
|
snapshotFlow { pager.settledPage }.collect { page ->
|
||||||
|
val monday = mondayAt(page)
|
||||||
|
if (monday != current.monday()) onDate(monday.plusDays(current.dayOfWeek.value - 1L))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalPager(state = pager, beyondViewportPageCount = 1) { page ->
|
||||||
|
val monday = mondayAt(page)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxWidth().background(weekTintOf(timetable?.days?.firstOrNull { it.date == monday.toString() }?.week)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
DayStrip(
|
||||||
|
monday = monday,
|
||||||
|
selected = date,
|
||||||
|
hasLessons = { true },
|
||||||
|
onSelect = onDate,
|
||||||
|
onWeek = { onDate(date.plusDays(7L * it)) },
|
||||||
|
modifier = Modifier.widthIn(max = MAX_CONTENT).padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weeks the search strip can page through on either side of today. */
|
||||||
|
private const val SPAN = 52
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionTitle(text: String) = Heading(text)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun RoomChips(rooms: List<String>, onClick: (String) -> Unit) {
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
rooms.forEach { room ->
|
||||||
|
Surface(
|
||||||
|
onClick = { onClick(room) },
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Rounded.MeetingRoom, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(room, style = MaterialTheme.typography.titleSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PairPicker(bells: List<Bell>, pair: Int, onSelect: (Int) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
bells.chunked(4).forEach { row ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
row.forEach { b ->
|
||||||
|
val on = b.pair == pair
|
||||||
|
val corner by animateDpAsState(
|
||||||
|
if (on) 12.dp else 20.dp,
|
||||||
|
MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||||
|
label = "pair-shape",
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
onClick = { onSelect(b.pair) },
|
||||||
|
shape = RoundedCornerShape(corner),
|
||||||
|
color = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.page,
|
||||||
|
contentColor = if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("${b.pair}", style = MaterialTheme.typography.titleMediumEmphasized)
|
||||||
|
Text(b.start, style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CoverageNote(c: Coverage, free: Boolean) {
|
||||||
|
Text(
|
||||||
|
"По расписаниям из Word: учтено ${c.documents} из ${c.total} документов каталога ${c.year}." +
|
||||||
|
(if (free) " Аудитория может быть занята мероприятием или закрыта — расписание этого не знает." else ""),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
190
android/app/src/main/kotlin/ritm/schedule/ui/SettingsScreen.kt
Normal file
190
android/app/src/main/kotlin/ritm/schedule/ui/SettingsScreen.kt
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import ritm.schedule.ui.theme.card
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import ritm.schedule.BuildConfig
|
||||||
|
import ritm.schedule.data.ThemeMode
|
||||||
|
import ritm.schedule.notify.Reminders
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||||
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
|
val s = settings ?: return
|
||||||
|
val context = LocalContext.current
|
||||||
|
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
vm.setReminders(granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
TopAppBar(
|
||||||
|
colors = appBarColors(),
|
||||||
|
title = { Text("Настройки", style = MaterialTheme.typography.titleLargeEmphasized) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Назад")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
ContentColumn {
|
||||||
|
Column(
|
||||||
|
Modifier.verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Section("Тема") {
|
||||||
|
ConnectedChoice(
|
||||||
|
options = listOf(ThemeMode.System to "Авто", ThemeMode.Light to "Светлая", ThemeMode.Dark to "Тёмная"),
|
||||||
|
selected = s.theme,
|
||||||
|
onSelect = vm::setTheme,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) SwitchRow(
|
||||||
|
"Цвета обоев",
|
||||||
|
"Цвета приложения подстраиваются под обои телефона",
|
||||||
|
s.dynamicColor,
|
||||||
|
vm::setDynamicColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Section("Расписание") {
|
||||||
|
val d = s.display
|
||||||
|
SwitchRow("Цвета предметов", "Каждый предмет своим цветом в карточках, сетке и обзоре", d.colors) {
|
||||||
|
vm.setDisplay(d.copy(colors = it))
|
||||||
|
}
|
||||||
|
SwitchRow("Преподаватели", "Фамилии в карточках занятий", d.teachers) { vm.setDisplay(d.copy(teachers = it)) }
|
||||||
|
SwitchRow("Аудитории", "Номера аудиторий в карточках и в сетке", d.rooms) { vm.setDisplay(d.copy(rooms = it)) }
|
||||||
|
SwitchRow("Номера пар", "«2 пара» рядом со временем", d.pairs) { vm.setDisplay(d.copy(pairs = it)) }
|
||||||
|
SwitchRow("Метки занятий", "Лекция или практика, белые и зелёные недели, общее занятие", d.tags) {
|
||||||
|
vm.setDisplay(d.copy(tags = it))
|
||||||
|
}
|
||||||
|
SwitchRow("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) {
|
||||||
|
vm.setDisplay(d.copy(notes = it))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Полные сведения о занятии всегда открываются по нажатию на карточку.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Section("Напоминания") {
|
||||||
|
SwitchRow(
|
||||||
|
"Перед парой",
|
||||||
|
"Уведомление с предметом и аудиторией. Работает без интернета, по сохранённому расписанию.",
|
||||||
|
s.reminders,
|
||||||
|
) { on ->
|
||||||
|
if (on && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !Reminders.canNotify(context))
|
||||||
|
permission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
else vm.setReminders(on)
|
||||||
|
}
|
||||||
|
if (s.reminders && !Reminders.canBeExact(context)) {
|
||||||
|
Text(
|
||||||
|
"Без разрешения на точные будильники Android может прислать напоминание на пару минут раньше или позже.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
androidx.compose.material3.OutlinedButton(onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
android.content.Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
|
||||||
|
.setData(android.net.Uri.parse("package:" + context.packageName)),
|
||||||
|
)
|
||||||
|
}) { Text("Разрешить точное время") }
|
||||||
|
}
|
||||||
|
if (s.reminders) {
|
||||||
|
Text("За сколько минут", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
ConnectedChoice(
|
||||||
|
options = listOf(5 to "5", 10 to "10", 15 to "15", 30 to "30"),
|
||||||
|
selected = s.reminderMinutes,
|
||||||
|
onSelect = vm::setReminderMinutes,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("О расписании") {
|
||||||
|
val doc = timetable?.document
|
||||||
|
Info("Документ", doc?.name ?: "Группа не выбрана")
|
||||||
|
timetable?.semester?.let { Info("Семестр", it.label) }
|
||||||
|
Info("Сервер", BuildConfig.API_URL)
|
||||||
|
Text(
|
||||||
|
"Расписание берётся из публичного каталога ПГУ и пересчитывается по таблице белых и зелёных недель. " +
|
||||||
|
"Приложение неофициальное: при сомнениях сверяйтесь с документом Word.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Section(title: String, content: @Composable () -> Unit) {
|
||||||
|
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.card, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Heading(title)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SwitchRow(title: String, text: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
// The whole row is the touch target, not only the switch.
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().toggleable(value = checked, role = Role.Switch, onValueChange = onChange),
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f).padding(end = 16.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Switch(checked = checked, onCheckedChange = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Info(label: String, value: String) {
|
||||||
|
Column {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(value, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
android/app/src/main/kotlin/ritm/schedule/ui/Subjects.kt
Normal file
102
android/app/src/main/kotlin/ritm/schedule/ui/Subjects.kt
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import ritm.schedule.data.Display
|
||||||
|
import ritm.schedule.data.Lesson
|
||||||
|
import ritm.schedule.data.Timetable
|
||||||
|
import ritm.schedule.data.parseTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subject's colours: [container]/[content] for small strong fills (week grid, dots),
|
||||||
|
* [soft]/[onSoft] for School Planner style lesson cards. Every pair keeps 4.5:1 contrast.
|
||||||
|
*/
|
||||||
|
data class SubjectColor(val container: Color, val content: Color, val soft: Color, val onSoft: Color)
|
||||||
|
|
||||||
|
// Material 700 tones (strong in light theme) and 200 tones (strong in dark theme).
|
||||||
|
private val TONE_700 = listOf(
|
||||||
|
0xFF1565C0, 0xFF7B1FA2, 0xFFC62828, 0xFF2E7D32, 0xFFD84315, 0xFF00695C,
|
||||||
|
0xFF283593, 0xFFAD1457, 0xFF5D4037, 0xFF00838F, 0xFF4527A0, 0xFF455A64,
|
||||||
|
).map { Color(it) }
|
||||||
|
|
||||||
|
private val TONE_200 = listOf(
|
||||||
|
0xFF90CAF9, 0xFFCE93D8, 0xFFEF9A9A, 0xFFA5D6A7, 0xFFFFAB91, 0xFF80CBC4,
|
||||||
|
0xFF9FA8DA, 0xFFF48FB1, 0xFFBCAAA4, 0xFF80DEEA, 0xFFB39DDB, 0xFFB0BEC5,
|
||||||
|
).map { Color(it) }
|
||||||
|
|
||||||
|
private val LIGHT = TONE_700.indices.map { i ->
|
||||||
|
SubjectColor(
|
||||||
|
container = TONE_700[i],
|
||||||
|
content = Color.White,
|
||||||
|
soft = lerp(TONE_200[i], Color.White, 0.35f),
|
||||||
|
onSoft = lerp(TONE_700[i], Color.Black, 0.35f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DARK = TONE_700.indices.map { i ->
|
||||||
|
SubjectColor(
|
||||||
|
container = TONE_200[i],
|
||||||
|
content = Color(0xFF1D1B20),
|
||||||
|
soft = lerp(Color(0xFF211F26), TONE_700[i], 0.5f),
|
||||||
|
onSoft = lerp(TONE_200[i], Color.White, 0.4f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subjectKey(subject: String) = subject.trim().lowercase()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette slot of every subject of the open timetable. Slots go in alphabetical order,
|
||||||
|
* so a subject keeps its colour across refreshes and two subjects rarely share one.
|
||||||
|
*/
|
||||||
|
fun subjectSlots(t: Timetable?): Map<String, Int> {
|
||||||
|
if (t == null) return emptyMap()
|
||||||
|
val subjects = (t.days.flatMap { it.lessons } + t.weekly.flatMap { it.lessons })
|
||||||
|
.map { subjectKey(it.subject) }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
return subjects.withIndex().associate { (i, key) -> key to i % LIGHT.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalSubjectSlots = staticCompositionLocalOf<Map<String, Int>> { emptyMap() }
|
||||||
|
|
||||||
|
/** The student's display choices from the settings. */
|
||||||
|
val LocalDisplay = staticCompositionLocalOf { Display() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun subjectColor(subject: String): SubjectColor {
|
||||||
|
if (!LocalDisplay.current.colors) {
|
||||||
|
// Colours off: every lesson in the calm theme tones.
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
return SubjectColor(scheme.primaryContainer, scheme.onPrimaryContainer, scheme.surfaceContainerHigh, scheme.onSurface)
|
||||||
|
}
|
||||||
|
val key = subjectKey(subject)
|
||||||
|
// Subjects of other groups (search results) still get a stable colour.
|
||||||
|
val slot = LocalSubjectSlots.current[key] ?: key.hashCode().mod(LIGHT.size)
|
||||||
|
val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||||
|
return (if (dark) DARK else LIGHT)[slot]
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SHORT_WORDS = setOf("и", "в", "во", "на", "по", "с", "со", "к", "о", "об", "для", "из", "от")
|
||||||
|
|
||||||
|
/** "Экономика предприятия в машиностроении" → "ЭПМ"; short names stay as they are. */
|
||||||
|
fun abbreviate(subject: String): String {
|
||||||
|
val name = subject.substringBefore(':').trim()
|
||||||
|
if (name.length <= 10) return name
|
||||||
|
val letters = name.split(' ', '-', '(', ')', ',')
|
||||||
|
.filter { it.isNotEmpty() && it.lowercase() !in SHORT_WORDS && it.first().isLetter() }
|
||||||
|
.joinToString("") { it.first().uppercase() }
|
||||||
|
// Four letters still fit a phone's week column at the default font size.
|
||||||
|
return letters.take(4).takeIf { it.length >= 2 } ?: name.take(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End of a lesson: its own end, else the bell table's, else 80 minutes after the start. */
|
||||||
|
fun lessonEnd(lesson: Lesson, t: Timetable): LocalTime =
|
||||||
|
lesson.end?.let(::parseTime)
|
||||||
|
?: t.bells.firstOrNull { it.start == lesson.start }?.end?.let(::parseTime)
|
||||||
|
?: parseTime(lesson.start).plusMinutes(80)
|
||||||
|
|
||||||
|
fun Color.luminance(): Float = 0.2126f * red + 0.7152f * green + 0.0722f * blue
|
||||||
362
android/app/src/main/kotlin/ritm/schedule/ui/SubjectsScreen.kt
Normal file
362
android/app/src/main/kotlin/ritm/schedule/ui/SubjectsScreen.kt
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.material.icons.rounded.DateRange
|
||||||
|
import androidx.compose.material.icons.rounded.Event
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import ritm.schedule.ui.theme.card
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.BarChart
|
||||||
|
import androidx.compose.material.icons.rounded.EventBusy
|
||||||
|
import androidx.compose.material.icons.rounded.LocationOn
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.MenuBook
|
||||||
|
import androidx.compose.material.icons.rounded.Person
|
||||||
|
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import ritm.schedule.data.Lesson
|
||||||
|
import ritm.schedule.data.Timetable
|
||||||
|
import ritm.schedule.data.longLabel
|
||||||
|
import ritm.schedule.data.monday
|
||||||
|
import ritm.schedule.data.parseDate
|
||||||
|
import ritm.schedule.data.plural
|
||||||
|
import ritm.schedule.data.shortWeekday
|
||||||
|
import ritm.schedule.data.today
|
||||||
|
import ritm.schedule.data.weekdayName
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
/** Everything the loaded weeks say about one subject. */
|
||||||
|
private class SubjectSummary(
|
||||||
|
val name: String,
|
||||||
|
val types: List<String>,
|
||||||
|
val teachers: List<String>,
|
||||||
|
val rooms: List<String>,
|
||||||
|
val upcoming: List<Pair<Slot, Lesson>>,
|
||||||
|
val weekDone: Int,
|
||||||
|
val weekTotal: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MainViewModel.summaries(t: Timetable, subgroup: String?, clock: LocalTime): List<SubjectSummary> {
|
||||||
|
val date = today()
|
||||||
|
val monday = date.monday()
|
||||||
|
val entries = t.days.flatMap { day -> slotsOn(t, parseDate(day.date), subgroup) }
|
||||||
|
.flatMap { slot -> slot.lessons.map { slot to it } }
|
||||||
|
return entries.groupBy { subjectKey(it.second.subject) }.values.map { items ->
|
||||||
|
val lessons = items.map { it.second }
|
||||||
|
val week = items.filter { it.first.date.monday() == monday }
|
||||||
|
SubjectSummary(
|
||||||
|
name = lessons.first().subject,
|
||||||
|
types = lessons.map { it.type.ifEmpty { "Занятие" } }.distinct(),
|
||||||
|
teachers = lessons.flatMap { it.teachers }.distinct(),
|
||||||
|
rooms = lessons.flatMap { it.rooms }.distinct(),
|
||||||
|
upcoming = items.filter { (slot, _) -> slot.date > date || (slot.date == date && slot.end.isAfter(clock)) },
|
||||||
|
weekDone = week.count { (slot, _) -> slot.date < date || (slot.date == date && !slot.end.isAfter(clock)) },
|
||||||
|
weekTotal = week.size,
|
||||||
|
)
|
||||||
|
}.sortedWith(
|
||||||
|
// Soonest first; subjects with nothing ahead go to the end.
|
||||||
|
compareBy<SubjectSummary, java.time.LocalDate?>(nullsLast()) { it.upcoming.firstOrNull()?.first?.date }
|
||||||
|
.thenBy(nullsLast()) { it.upcoming.firstOrNull()?.first?.start }
|
||||||
|
.thenBy { it.name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SubjectsScreen(vm: MainViewModel, nav: AppNav) {
|
||||||
|
val timetable by vm.timetable.collectAsStateWithLifecycle()
|
||||||
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
|
var open by remember { mutableStateOf<SubjectSummary?>(null) }
|
||||||
|
val clock = rememberClock()
|
||||||
|
|
||||||
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
TopAppBar(
|
||||||
|
colors = appBarColors(),
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
title = { Text("Предметы", style = MaterialTheme.typography.titleLargeEmphasized) },
|
||||||
|
navigationIcon = { MenuButton(nav) },
|
||||||
|
)
|
||||||
|
val t = timetable
|
||||||
|
when {
|
||||||
|
t == null && status.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
||||||
|
t == null -> Empty("Предметов пока нет", "Выберите группу — предметы появятся из её расписания.", icon = Icons.AutoMirrored.Rounded.MenuBook, action = "Выбрать группу" to nav.openGroups)
|
||||||
|
t.semester == null -> Empty("Предметы недоступны", "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", icon = Icons.Rounded.EventBusy)
|
||||||
|
else -> ContentColumn {
|
||||||
|
val subgroup = settings?.subgroup
|
||||||
|
val subjects = vm.summaries(t, subgroup, clock)
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp + navBarPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item { WeekLoad(vm, t, subgroup, subjects.size) }
|
||||||
|
item {
|
||||||
|
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
Heading("Все предметы", style = MaterialTheme.typography.titleLargeEmphasized, modifier = Modifier.weight(1f))
|
||||||
|
Text("по ближайшему занятию", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(subjects, key = { subjectKey(it.name) }) { s -> SubjectCard(s, onClick = { open = s }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open?.let { s ->
|
||||||
|
ModalBottomSheet(onDismissRequest = { open = null }) { SubjectDetails(s) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pairs per weekday of the current week: a count over each bar, today's bar in the strong tone. */
|
||||||
|
@Composable
|
||||||
|
private fun WeekLoad(vm: MainViewModel, t: Timetable, subgroup: String?, subjects: Int) {
|
||||||
|
val date = today()
|
||||||
|
val monday = date.monday()
|
||||||
|
val days = (0..6L).map { monday.plusDays(it) }.map { it to vm.slotsOn(t, it, subgroup).size }
|
||||||
|
.filter { (d, n) -> d.dayOfWeek.value < 7 || n > 0 }
|
||||||
|
val total = days.sumOf { it.second }
|
||||||
|
val most = days.maxOf { it.second }.coerceAtLeast(1)
|
||||||
|
val busiest = days.filter { it.second == most && most > 0 }.map { it.first.weekdayName().lowercase() }
|
||||||
|
SectionCard(Icons.Rounded.BarChart, "Эта неделя") {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"$total ${plural(total, "пара", "пары", "пар")} · $subjects ${plural(subjects, "предмет", "предмета", "предметов")}",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
|
)
|
||||||
|
if (busiest.isNotEmpty()) Text(
|
||||||
|
"Больше всего — ${busiest.joinToString(" и ")}, $most ${plural(most, "пара", "пары", "пар")}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
|
||||||
|
contentDescription = days.joinToString(", ") { (d, n) -> "${d.weekdayName()}: $n" }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
days.forEach { (d, n) ->
|
||||||
|
val isToday = d == date
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f).clearAndSetSemantics {}) {
|
||||||
|
Text(
|
||||||
|
if (n > 0) "$n" else "—",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = when {
|
||||||
|
isToday -> MaterialTheme.colorScheme.primary
|
||||||
|
d < date -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||||
|
else -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
},
|
||||||
|
modifier = Modifier.width(28.dp).height((4 + 60 * n / most).dp),
|
||||||
|
) {}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
d.shortWeekday(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Сегодня, 11:45", "Завтра, 10:05" or "Пн, 19 октября, 10:05". */
|
||||||
|
private fun nextLabel(slot: Slot): String {
|
||||||
|
val date = today()
|
||||||
|
val day = when (slot.date) {
|
||||||
|
date -> "Сегодня"
|
||||||
|
date.plusDays(1) -> "Завтра"
|
||||||
|
else -> slot.date.shortWeekday() + ", " + slot.date.longLabel()
|
||||||
|
}
|
||||||
|
return "$day, ${slot.start}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subject's short label: the same letters the week grid shows. */
|
||||||
|
fun subjectBadge(name: String): String {
|
||||||
|
val short = abbreviate(name)
|
||||||
|
return if (short.length <= 4) short
|
||||||
|
else short.split(' ').filter { it.isNotEmpty() }.take(2).joinToString("") { it.first().uppercase() }
|
||||||
|
.let { if (it.length >= 2) it else short.take(2).replaceFirstChar { c -> c.uppercase() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tinted square with the subject's grid letters, so a block in the week grid is easy to find here. */
|
||||||
|
@Composable
|
||||||
|
private fun SubjectBadge(name: String, size: androidx.compose.ui.unit.Dp = 48.dp) {
|
||||||
|
val color = subjectColor(name)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(size)
|
||||||
|
.background(color.container, MaterialTheme.shapes.large)
|
||||||
|
.clearAndSetSemantics {},
|
||||||
|
) {
|
||||||
|
Text(subjectBadge(name), style = MaterialTheme.typography.titleSmallEmphasized, color = color.content, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) {
|
||||||
|
val show = LocalDisplay.current
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.card,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SubjectBadge(s.name)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(s.name, style = MaterialTheme.typography.titleMediumEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (show.tags) Text(
|
||||||
|
s.types.joinToString(" · ").lowercase().replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.page, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
val next = s.upcoming.firstOrNull()
|
||||||
|
Fact(
|
||||||
|
Icons.Rounded.Event,
|
||||||
|
next?.let { (slot, lesson) ->
|
||||||
|
nextLabel(slot) + (lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { ", ауд. ${it.joinToString(", ")}" } ?: "")
|
||||||
|
} ?: "В загруженных неделях больше нет",
|
||||||
|
strong = next != null,
|
||||||
|
)
|
||||||
|
if (show.teachers && s.teachers.isNotEmpty()) Fact(Icons.Rounded.Person, s.teachers.joinToString(", "))
|
||||||
|
Fact(
|
||||||
|
Icons.Rounded.DateRange,
|
||||||
|
if (s.weekTotal == 0) "На этой неделе пар нет"
|
||||||
|
else "На этой неделе ${s.weekTotal} ${plural(s.weekTotal, "пара", "пары", "пар")}" +
|
||||||
|
when (s.weekDone) {
|
||||||
|
0 -> ""
|
||||||
|
s.weekTotal -> ", все прошли"
|
||||||
|
else -> ", прошло ${s.weekDone}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Fact(icon: ImageVector, text: String, strong: Boolean = false) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(text, style = if (strong) MaterialTheme.typography.bodyLargeEmphasized else MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SubjectDetails(s: SubjectSummary) {
|
||||||
|
val color = subjectColor(s.name)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SubjectBadge(s.name, 56.dp)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Heading(s.name, style = MaterialTheme.typography.headlineSmallEmphasized)
|
||||||
|
}
|
||||||
|
Text(s.types.joinToString(" · "), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
DetailLine(Icons.Rounded.Person, "Преподаватели", s.teachers.joinToString(", ").ifEmpty { "Не указаны" })
|
||||||
|
DetailLine(Icons.Rounded.LocationOn, "Аудитории", s.rooms.joinToString(", ").ifEmpty { "Не указаны" })
|
||||||
|
Heading("Ближайшие занятия", style = MaterialTheme.typography.titleMediumEmphasized)
|
||||||
|
if (s.upcoming.isEmpty()) Text("В загруженных неделях занятий больше нет.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
s.upcoming.take(12).groupBy { it.first.date }.forEach { (date, items) ->
|
||||||
|
Text(
|
||||||
|
date.weekdayName() + ", " + date.longLabel(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
// The subject is the sheet's title, so each row keeps only when, where and what.
|
||||||
|
items.forEach { (slot, lesson) ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SubjectDot(lesson.subject, 10.dp)
|
||||||
|
Spacer(Modifier.width(18.dp))
|
||||||
|
Column {
|
||||||
|
Text("${slot.start} → ${slot.end}", style = MaterialTheme.typography.titleSmallEmphasized)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
lesson.type.ifEmpty { null },
|
||||||
|
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
|
||||||
|
lesson.teachers.joinToString(", ").ifEmpty { null },
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DetailLine(icon: ImageVector, label: String, value: String) {
|
||||||
|
Row {
|
||||||
|
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(value, style = MaterialTheme.typography.bodyLargeEmphasized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
206
android/app/src/main/kotlin/ritm/schedule/ui/WeekGrid.kt
Normal file
206
android/app/src/main/kotlin/ritm/schedule/ui/WeekGrid.kt
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
|
||||||
|
package ritm.schedule.ui
|
||||||
|
|
||||||
|
import ritm.schedule.ui.theme.card
|
||||||
|
import ritm.schedule.ui.theme.page
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
|
import kotlin.math.abs
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ritm.schedule.data.Lesson
|
||||||
|
import ritm.schedule.data.Timetable
|
||||||
|
import ritm.schedule.data.longLabel
|
||||||
|
import ritm.schedule.data.shortWeekday
|
||||||
|
import ritm.schedule.data.today
|
||||||
|
import ritm.schedule.data.weekdayName
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
private val HOUR = 60.dp
|
||||||
|
private val GUTTER = 36.dp
|
||||||
|
|
||||||
|
/** The week as a time grid: one column per day, a coloured block per lesson. */
|
||||||
|
@Composable
|
||||||
|
fun WeekGrid(
|
||||||
|
vm: MainViewModel,
|
||||||
|
t: Timetable,
|
||||||
|
subgroup: String?,
|
||||||
|
monday: LocalDate,
|
||||||
|
onOpen: (Lesson) -> Unit,
|
||||||
|
onWeek: (Int) -> Unit,
|
||||||
|
headerColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val clock = rememberClock()
|
||||||
|
val all = (0..6L).map { monday.plusDays(it) }.map { it to vm.slotsOn(t, it, subgroup) }
|
||||||
|
// Sunday only takes a column when something happens on it.
|
||||||
|
val days = all.filter { (d, slots) -> d.dayOfWeek.value < 7 || slots.isNotEmpty() }
|
||||||
|
val slots = days.flatMap { it.second }
|
||||||
|
val first = minOf(8, slots.minOfOrNull { it.start.hour } ?: 8)
|
||||||
|
val last = maxOf(18, slots.maxOfOrNull { it.end.hour + if (it.end.minute > 0) 1 else 0 } ?: 18)
|
||||||
|
val hours = last - first
|
||||||
|
fun top(time: LocalTime) = HOUR * ((time.hour - first) * 60 + time.minute) / 60f
|
||||||
|
|
||||||
|
val line = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
Column(modifier) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.background(headerColor)
|
||||||
|
.padding(start = GUTTER, bottom = 8.dp)
|
||||||
|
.semantics {
|
||||||
|
customActions = listOf(
|
||||||
|
CustomAccessibilityAction("Предыдущая неделя") { onWeek(-1); true },
|
||||||
|
CustomAccessibilityAction("Следующая неделя") { onWeek(1); true },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
days.forEach { (d, _) ->
|
||||||
|
val isToday = d == today()
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.semantics(mergeDescendants = true) {
|
||||||
|
contentDescription = "${d.weekdayName()}, ${d.longLabel()}"
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
d.shortWeekday(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.background(if (isToday) MaterialTheme.colorScheme.primary else Color.Transparent, CircleShape),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${d.dayOfMonth}",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
// Room above the first hour label, which sits on its line.
|
||||||
|
.padding(top = 8.dp, bottom = 24.dp + navBarPadding())
|
||||||
|
.height(HOUR * hours),
|
||||||
|
) {
|
||||||
|
Box(Modifier.width(GUTTER).fillMaxHeight()) {
|
||||||
|
for (h in first until last) Text(
|
||||||
|
"$h",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.offset(x = 12.dp, y = HOUR * (h - first) - 6.dp).clearAndSetSemantics {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
days.forEach { (d, daySlots) ->
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.drawBehind {
|
||||||
|
val step = size.height / hours
|
||||||
|
for (h in 0..hours) drawLine(line, Offset(0f, step * h), Offset(size.width, step * h), 1f)
|
||||||
|
drawLine(line, Offset(0f, 0f), Offset(0f, size.height), 1f)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val wide = maxWidth >= 96.dp
|
||||||
|
daySlots.forEach { slot ->
|
||||||
|
val y = top(slot.start)
|
||||||
|
val height = top(slot.end) - y
|
||||||
|
val width = maxWidth / slot.lessons.size
|
||||||
|
slot.lessons.forEachIndexed { i, lesson ->
|
||||||
|
LessonBlock(
|
||||||
|
lesson, slot, wide,
|
||||||
|
onClick = { onOpen(lesson) },
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = width * i, y = y)
|
||||||
|
.size(width, height)
|
||||||
|
.padding(horizontal = 2.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d == today() && clock.hour in first until last) {
|
||||||
|
val y = top(clock)
|
||||||
|
Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.primary))
|
||||||
|
Box(Modifier.offset(x = (-5).dp, y = y - 5.dp).size(10.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
val color = subjectColor(lesson.subject)
|
||||||
|
val room = when {
|
||||||
|
lesson.distance -> "дист."
|
||||||
|
LocalDisplay.current.rooms -> lesson.rooms.joinToString(", ")
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = color.container,
|
||||||
|
contentColor = color.content,
|
||||||
|
modifier = modifier.semantics {
|
||||||
|
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
|
||||||
|
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "")
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(horizontal = 6.dp, vertical = 4.dp).clearAndSetSemantics {}, verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(
|
||||||
|
if (wide) lesson.subject else abbreviate(lesson.subject),
|
||||||
|
style = MaterialTheme.typography.labelLargeEmphasized,
|
||||||
|
maxLines = if (wide) 3 else 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (room.isNotEmpty()) Text(room, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
android/app/src/main/kotlin/ritm/schedule/ui/theme/Theme.kt
Normal file
126
android/app/src/main/kotlin/ritm/schedule/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package ritm.schedule.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MotionScheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import ritm.schedule.data.ThemeMode
|
||||||
|
|
||||||
|
// The same tokens as the web app (src/styles.css), so both clients look alike.
|
||||||
|
private val Light = lightColorScheme(
|
||||||
|
primary = Color(0xFF6750A4),
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = Color(0xFFEADDFF),
|
||||||
|
onPrimaryContainer = Color(0xFF21005D),
|
||||||
|
secondary = Color(0xFF625B71),
|
||||||
|
onSecondary = Color.White,
|
||||||
|
secondaryContainer = Color(0xFFE8DEF8),
|
||||||
|
onSecondaryContainer = Color(0xFF1D192B),
|
||||||
|
tertiary = Color(0xFF7D5260),
|
||||||
|
onTertiary = Color.White,
|
||||||
|
tertiaryContainer = Color(0xFFFFD8E4),
|
||||||
|
onTertiaryContainer = Color(0xFF31111D),
|
||||||
|
surface = Color(0xFFFEF7FF),
|
||||||
|
onSurface = Color(0xFF1D1B20),
|
||||||
|
onSurfaceVariant = Color(0xFF49454F),
|
||||||
|
surfaceContainerLowest = Color.White,
|
||||||
|
surfaceContainerLow = Color(0xFFF7F2FA),
|
||||||
|
surfaceContainer = Color(0xFFF3EDF7),
|
||||||
|
surfaceContainerHigh = Color(0xFFECE6F0),
|
||||||
|
surfaceContainerHighest = Color(0xFFE6E0E9),
|
||||||
|
outline = Color(0xFF79747E),
|
||||||
|
outlineVariant = Color(0xFFCAC4D0),
|
||||||
|
error = Color(0xFFB3261E),
|
||||||
|
errorContainer = Color(0xFFF9DEDC),
|
||||||
|
onErrorContainer = Color(0xFF410E0B),
|
||||||
|
background = Color(0xFFFEF7FF),
|
||||||
|
onBackground = Color(0xFF1D1B20),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val Dark = darkColorScheme(
|
||||||
|
primary = Color(0xFFD0BCFF),
|
||||||
|
onPrimary = Color(0xFF381E72),
|
||||||
|
primaryContainer = Color(0xFF4F378B),
|
||||||
|
onPrimaryContainer = Color(0xFFEADDFF),
|
||||||
|
secondary = Color(0xFFCCC2DC),
|
||||||
|
onSecondary = Color(0xFF332D41),
|
||||||
|
secondaryContainer = Color(0xFF4A4458),
|
||||||
|
onSecondaryContainer = Color(0xFFE8DEF8),
|
||||||
|
tertiary = Color(0xFFEFB8C8),
|
||||||
|
onTertiary = Color(0xFF492532),
|
||||||
|
tertiaryContainer = Color(0xFF633B48),
|
||||||
|
onTertiaryContainer = Color(0xFFFFD8E4),
|
||||||
|
surface = Color(0xFF141218),
|
||||||
|
onSurface = Color(0xFFE6E0E9),
|
||||||
|
onSurfaceVariant = Color(0xFFCAC4D0),
|
||||||
|
surfaceContainerLowest = Color(0xFF0F0D13),
|
||||||
|
surfaceContainerLow = Color(0xFF1D1B20),
|
||||||
|
surfaceContainer = Color(0xFF211F26),
|
||||||
|
surfaceContainerHigh = Color(0xFF2B2930),
|
||||||
|
surfaceContainerHighest = Color(0xFF36343B),
|
||||||
|
outline = Color(0xFF938F99),
|
||||||
|
outlineVariant = Color(0xFF49454F),
|
||||||
|
error = Color(0xFFF2B8B5),
|
||||||
|
errorContainer = Color(0xFF8C1D18),
|
||||||
|
onErrorContainer = Color(0xFFF9DEDC),
|
||||||
|
background = Color(0xFF141218),
|
||||||
|
onBackground = Color(0xFFE6E0E9),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The tinted page behind cards, app bars and the navigation rail. */
|
||||||
|
val ColorScheme.page: Color get() = surfaceContainer
|
||||||
|
|
||||||
|
/** Cards lift off the page: white in the light theme, a lighter tone in the dark one. */
|
||||||
|
val ColorScheme.card: Color
|
||||||
|
get() = if (surface.red + surface.green + surface.blue < 1.5f) surfaceContainerHigh else surfaceContainerLowest
|
||||||
|
|
||||||
|
/** Week colours from the university table: white and green weeks. */
|
||||||
|
object WeekColors {
|
||||||
|
fun container(white: Boolean, dark: Boolean) = when {
|
||||||
|
white && dark -> Color(0xFFE6E0E9)
|
||||||
|
white -> Color.White
|
||||||
|
dark -> Color(0xFF3D6A3A)
|
||||||
|
else -> Color(0xFFC8EFB8)
|
||||||
|
}
|
||||||
|
/** A strong green for small marks, readable on both themes. */
|
||||||
|
fun mark(dark: Boolean) = if (dark) Color(0xFF81C784) else Color(0xFF2E7D32)
|
||||||
|
fun content(white: Boolean, dark: Boolean) = when {
|
||||||
|
white -> Color(0xFF1D1B20)
|
||||||
|
dark -> Color(0xFFD5E8D4)
|
||||||
|
else -> Color(0xFF284F34)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun isDark(mode: ThemeMode): Boolean = when (mode) {
|
||||||
|
ThemeMode.System -> isSystemInDarkTheme()
|
||||||
|
ThemeMode.Light -> false
|
||||||
|
ThemeMode.Dark -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ScheduleTheme(mode: ThemeMode, dynamicColor: Boolean, content: @Composable () -> Unit) {
|
||||||
|
val dark = isDark(mode)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scheme: ColorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
|
||||||
|
if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
dark -> Dark
|
||||||
|
else -> Light
|
||||||
|
}
|
||||||
|
MaterialExpressiveTheme(
|
||||||
|
colorScheme = scheme,
|
||||||
|
motionScheme = MotionScheme.expressive(),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
154
android/app/src/main/kotlin/ritm/schedule/widget/TodayWidget.kt
Normal file
154
android/app/src/main/kotlin/ritm/schedule/widget/TodayWidget.kt
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package ritm.schedule.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.action.actionStartActivity
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import ritm.schedule.MainActivity
|
||||||
|
import ritm.schedule.app
|
||||||
|
import ritm.schedule.data.Lesson
|
||||||
|
import ritm.schedule.data.longLabel
|
||||||
|
import ritm.schedule.data.minutesLabel
|
||||||
|
import ritm.schedule.data.nowTime
|
||||||
|
import ritm.schedule.data.parseTime
|
||||||
|
import ritm.schedule.data.today
|
||||||
|
import ritm.schedule.data.weekdayName
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** What the widget shows: the lesson in progress and what comes next, today or tomorrow. */
|
||||||
|
private data class WidgetState(
|
||||||
|
val group: String?,
|
||||||
|
val title: String,
|
||||||
|
val lines: List<Pair<String, String>>,
|
||||||
|
val empty: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun state(context: Context): WidgetState {
|
||||||
|
val app = context.app
|
||||||
|
val timetable = app.repository.timetable.value
|
||||||
|
?: return WidgetState(null, "Расписание", emptyList(), "Откройте приложение и выберите группу")
|
||||||
|
val subgroup = app.settings.now().subgroup
|
||||||
|
val now = nowTime()
|
||||||
|
val todayLessons = app.repository.lessonsOn(timetable, today(), subgroup)
|
||||||
|
val left = todayLessons.filter { parseTime(it.end ?: it.start).isAfter(now) }
|
||||||
|
val (date: LocalDate, list: List<Lesson>) =
|
||||||
|
if (left.isNotEmpty()) today() to left
|
||||||
|
else today().plusDays(1) to app.repository.lessonsOn(timetable, today().plusDays(1), subgroup)
|
||||||
|
val title = if (date == today()) "Сегодня, ${date.longLabel()}" else "Завтра, ${date.weekdayName().lowercase()}"
|
||||||
|
val lines = list.groupBy { it.start }.entries.take(3).map { (start, lessons) ->
|
||||||
|
val l = lessons.first()
|
||||||
|
val begins = parseTime(start)
|
||||||
|
val ends = l.end?.let(::parseTime)
|
||||||
|
val label = when {
|
||||||
|
date == today() && !now.isBefore(begins) && ends != null && now.isBefore(ends) ->
|
||||||
|
"Идёт · ещё ${minutesLabel(Duration.between(now, ends).toMinutes())}"
|
||||||
|
date == today() && begins.isAfter(now) &&
|
||||||
|
Duration.between(now, begins).toMinutes() <= 120 ->
|
||||||
|
"$start · через ${minutesLabel(Duration.between(now, begins).toMinutes())}"
|
||||||
|
else -> start + (l.pair?.let { " · $it пара" } ?: "")
|
||||||
|
}
|
||||||
|
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
|
||||||
|
label to (lessons.joinToString(" / ") { it.subject } + if (place.isNotEmpty()) " · ауд. $place" else "")
|
||||||
|
}
|
||||||
|
return WidgetState(
|
||||||
|
timetable.group,
|
||||||
|
title,
|
||||||
|
lines,
|
||||||
|
if (lines.isEmpty()) "Занятий нет" else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class TodayWidget : GlanceAppWidget() {
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val data = state(context)
|
||||||
|
provideContent { GlanceTheme { Content(data) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Content(data: WidgetState) {
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(GlanceTheme.colors.widgetBackground)
|
||||||
|
.cornerRadius(24.dp)
|
||||||
|
.padding(16.dp)
|
||||||
|
.clickable(actionStartActivity<MainActivity>()),
|
||||||
|
) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
data.group ?: "Расписание",
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.primary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(GlanceModifier.defaultWeight())
|
||||||
|
Text(data.title, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp))
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(8.dp))
|
||||||
|
data.empty?.let {
|
||||||
|
Text(it, style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp))
|
||||||
|
}
|
||||||
|
data.lines.forEachIndexed { index, (label, text) ->
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
if (index == 0) GlanceTheme.colors.primaryContainer
|
||||||
|
else GlanceTheme.colors.secondaryContainer,
|
||||||
|
)
|
||||||
|
.cornerRadius(16.dp)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = TextStyle(
|
||||||
|
color = if (index == 0) GlanceTheme.colors.onPrimaryContainer
|
||||||
|
else GlanceTheme.colors.onSecondaryContainer,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
maxLines = 2,
|
||||||
|
style = TextStyle(
|
||||||
|
color = if (index == 0) GlanceTheme.colors.onPrimaryContainer
|
||||||
|
else GlanceTheme.colors.onSecondaryContainer,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TodayWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
|
||||||
|
}
|
||||||
56
android/app/src/main/kotlin/ritm/schedule/work/SyncWorker.kt
Normal file
56
android/app/src/main/kotlin/ritm/schedule/work/SyncWorker.kt
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package ritm.schedule.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import ritm.schedule.app
|
||||||
|
import ritm.schedule.notify.Reminders
|
||||||
|
import ritm.schedule.widget.TodayWidget
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every few hours: fetch the timetable (the university republishes files),
|
||||||
|
* redraw the widget and plan the next reminders. Offline runs reuse the cache.
|
||||||
|
*/
|
||||||
|
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val app = applicationContext.app
|
||||||
|
app.repository.refresh()
|
||||||
|
afterChange(applicationContext)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
|
||||||
|
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||||
|
.build()
|
||||||
|
val tick = PeriodicWorkRequestBuilder<WidgetTick>(15, TimeUnit.MINUTES).build()
|
||||||
|
WorkManager.getInstance(context).apply {
|
||||||
|
enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
enqueueUniquePeriodicWork("widget", ExistingPeriodicWorkPolicy.KEEP, tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widget and reminders follow the cached timetable and settings. */
|
||||||
|
suspend fun afterChange(context: Context) {
|
||||||
|
val app = context.app
|
||||||
|
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
|
||||||
|
TodayWidget().updateAll(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps "now / next lesson" on the widget current; no network needed. */
|
||||||
|
class WidgetTick(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
TodayWidget().updateAll(applicationContext)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
18
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
18
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Material Symbols "calendar_month" in the adaptive icon safe zone. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group
|
||||||
|
android:pivotX="480"
|
||||||
|
android:pivotY="480"
|
||||||
|
android:scaleX="0.5"
|
||||||
|
android:scaleY="0.5"
|
||||||
|
android:translateY="480">
|
||||||
|
<path
|
||||||
|
android:fillColor="#4F378B"
|
||||||
|
android:pathData="M200,-80Q167,-80 143.5,-103.5Q120,-127 120,-160L120,-720Q120,-753 143.5,-776.5Q167,-800 200,-800L240,-800L240,-840Q240,-857 251.5,-868.5Q263,-880 280,-880Q297,-880 308.5,-868.5Q320,-857 320,-840L320,-800L640,-800L640,-840Q640,-857 651.5,-868.5Q663,-880 680,-880Q697,-880 708.5,-868.5Q720,-857 720,-840L720,-800L760,-800Q793,-800 816.5,-776.5Q840,-753 840,-720L840,-160Q840,-127 816.5,-103.5Q793,-80 760,-80L200,-80ZM200,-160L760,-160Q760,-160 760,-160Q760,-160 760,-160L760,-560L200,-560L200,-160Q200,-160 200,-160Q200,-160 200,-160ZM480,-400Q463,-400 451.5,-411.5Q440,-423 440,-440Q440,-457 451.5,-468.5Q463,-480 480,-480Q497,-480 508.5,-468.5Q520,-457 520,-440Q520,-423 508.5,-411.5Q497,-400 480,-400ZM320,-400Q303,-400 291.5,-411.5Q280,-423 280,-440Q280,-457 291.5,-468.5Q303,-480 320,-480Q337,-480 348.5,-468.5Q360,-457 360,-440Q360,-423 348.5,-411.5Q337,-400 320,-400ZM640,-400Q623,-400 611.5,-411.5Q600,-423 600,-440Q600,-457 611.5,-468.5Q623,-480 640,-480Q657,-480 668.5,-468.5Q680,-457 680,-440Q680,-423 668.5,-411.5Q657,-400 640,-400ZM480,-240Q463,-240 451.5,-251.5Q440,-263 440,-280Q440,-297 451.5,-308.5Q463,-320 480,-320Q497,-320 508.5,-308.5Q520,-297 520,-280Q520,-263 508.5,-251.5Q497,-240 480,-240ZM320,-240Q303,-240 291.5,-251.5Q280,-263 280,-280Q280,-297 291.5,-308.5Q303,-320 320,-320Q337,-320 348.5,-308.5Q360,-297 360,-280Q360,-263 348.5,-251.5Q337,-240 320,-240ZM640,-240Q623,-240 611.5,-251.5Q600,-263 600,-280Q600,-297 611.5,-308.5Q623,-320 640,-320Q657,-320 668.5,-308.5Q680,-297 680,-280Q680,-263 668.5,-251.5Q657,-240 640,-240Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
12
android/app/src/main/res/drawable/ic_notification.xml
Normal file
12
android/app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M200,-80Q167,-80 143.5,-103.5Q120,-127 120,-160L120,-720Q120,-753 143.5,-776.5Q167,-800 200,-800L240,-800L240,-840Q240,-857 251.5,-868.5Q263,-880 280,-880Q297,-880 308.5,-868.5Q320,-857 320,-840L320,-800L640,-800L640,-840Q640,-857 651.5,-868.5Q663,-880 680,-880Q697,-880 708.5,-868.5Q720,-857 720,-840L720,-800L760,-800Q793,-800 816.5,-776.5Q840,-753 840,-720L840,-160Q840,-127 816.5,-103.5Q793,-80 760,-80L200,-80ZM200,-160L760,-160L760,-560L200,-560L200,-160Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
4
android/app/src/main/res/values-night/colors.xml
Normal file
4
android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="surface">#141218</color>
|
||||||
|
</resources>
|
||||||
8
android/app/src/main/res/values-night/themes.xml
Normal file
8
android/app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.PsuSchedule" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/surface</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
6
android/app/src/main/res/values/colors.xml
Normal file
6
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="surface">#FEF7FF</color>
|
||||||
|
<color name="primary">#6750A4</color>
|
||||||
|
<color name="launcher_background">#EADDFF</color>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values/strings.xml
Normal file
7
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Расписание ПГУ</string>
|
||||||
|
<string name="widget_name">Сегодня</string>
|
||||||
|
<string name="widget_description">Текущая и следующая пара вашей группы</string>
|
||||||
|
<string name="channel_reminders">Перед парой</string>
|
||||||
|
</resources>
|
||||||
9
android/app/src/main/res/values/themes.xml
Normal file
9
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Only the window before Compose draws; colours match the app surface. -->
|
||||||
|
<style name="Theme.PsuSchedule" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/surface</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
10
android/app/src/main/res/xml/network_security_config.xml
Normal file
10
android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- HTTPS everywhere; plain HTTP only for a local development server
|
||||||
|
(10.0.2.2 is the host computer as seen from the emulator). -->
|
||||||
|
<network-security-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||||
|
<domain includeSubdomains="false">localhost</domain>
|
||||||
|
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
11
android/app/src/main/res/xml/today_widget_info.xml
Normal file
11
android/app/src/main/res/xml/today_widget_info.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:description="@string/widget_description"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
|
android:minWidth="180dp"
|
||||||
|
android:minHeight="110dp"
|
||||||
|
android:targetCellWidth="3"
|
||||||
|
android:targetCellHeight="2"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:widgetCategory="home_screen" />
|
||||||
5
android/build.gradle.kts
Normal file
5
android/build.gradle.kts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
11
android/gradle.properties
Normal file
11
android/gradle.properties
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Адрес API. По умолчанию — сервер из docker-compose на этом компьютере,
|
||||||
|
# видимый из эмулятора как 10.0.2.2. Для релиза нужен публичный HTTPS-адрес:
|
||||||
|
# ./gradlew assembleRelease -PPSU_API_URL=https://schedule.example.by/
|
||||||
|
PSU_API_URL=http://10.0.2.2:3010/
|
||||||
41
android/gradle/libs.versions.toml
Normal file
41
android/gradle/libs.versions.toml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.4.1"
|
||||||
|
kotlin = "2.4.20"
|
||||||
|
composeBom = "2026.09.01"
|
||||||
|
activity = "1.13.0"
|
||||||
|
lifecycle = "2.11.0"
|
||||||
|
coreKtx = "1.19.1"
|
||||||
|
coroutines = "1.11.0"
|
||||||
|
serialization = "1.11.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
datastore = "1.2.1"
|
||||||
|
glance = "1.2.0"
|
||||||
|
work = "2.12.0"
|
||||||
|
desugar = "2.1.5"
|
||||||
|
junit = "4.13.2"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
compose-bom = { module = "androidx.compose:compose-bom-alpha", version.ref = "composeBom" }
|
||||||
|
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
|
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
|
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
|
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
compose-icons = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
|
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||||
|
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||||
|
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
|
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||||
|
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
|
glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
|
||||||
|
glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" }
|
||||||
|
work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||||
|
desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" }
|
||||||
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
android/gradlew
vendored
Executable file
248
android/gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 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
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# gradlew 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 gradlew
|
||||||
|
#
|
||||||
|
# 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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\n' "$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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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" )
|
||||||
|
|
||||||
|
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, 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" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# 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" "$@"
|
||||||
82
android/gradlew.bat
vendored
Normal file
82
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
@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 gradlew startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
: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
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute gradlew
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
16
android/settings.gradle.kts
Normal file
16
android/settings.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "PsuSchedule"
|
||||||
|
include(":app")
|
||||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { parseWord, PARSER_VERSION } from "./parser.js";
|
import { parseWord, PARSER_VERSION } from "./parser.js";
|
||||||
import { scheduleFor } from "./schedules.js";
|
import { scheduleFor } from "./schedules.js";
|
||||||
|
import { timetable } from "./timetable.js";
|
||||||
import {
|
import {
|
||||||
getIndex,
|
getIndex,
|
||||||
indexStatus,
|
indexStatus,
|
||||||
@@ -87,6 +88,29 @@ app.get("/api/groups", async (req, res, next) => {
|
|||||||
next(e);
|
next(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// A group's lessons by date with weeks applied: for native and other clients.
|
||||||
|
app.get("/api/timetable", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const doc = String(req.query.doc || ""),
|
||||||
|
group = String(req.query.group || "").slice(0, 40),
|
||||||
|
from = req.query.from || todayIso();
|
||||||
|
if (!doc || !group) return badRequest(res, "Укажите документ (doc) и группу (group).");
|
||||||
|
if (!isDate(from)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
|
||||||
|
const file = await documentFor(doc);
|
||||||
|
res.json(
|
||||||
|
timetable({
|
||||||
|
file,
|
||||||
|
schedule: await scheduleFor(file),
|
||||||
|
calendar: await directoryCalendar(),
|
||||||
|
group,
|
||||||
|
from,
|
||||||
|
days: req.query.days,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
app.get("/api/exams", async (req, res, next) => {
|
app.get("/api/exams", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const group = String(req.query.group || "").slice(0, 40);
|
const group = String(req.query.group || "").slice(0, 40);
|
||||||
|
|||||||
101
server/timetable.js
Normal file
101
server/timetable.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// A group's timetable by date, with week numbers already applied. Native and
|
||||||
|
// third-party clients use it instead of re-implementing the white/green week rules.
|
||||||
|
import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js";
|
||||||
|
import { lessonKey, subgroupsOf } from "../shared/lessons.js";
|
||||||
|
import { occurrence, peopleAndRooms, roomsOn } from "./directory.js";
|
||||||
|
|
||||||
|
const MAX_DAYS = 62;
|
||||||
|
|
||||||
|
function lessonOut(lesson, group, people, date, status, calendar) {
|
||||||
|
const bell = calendar?.bells.find((b) => b.start === lesson.start);
|
||||||
|
const rooms = date ? roomsOn(people, date) : people.rooms;
|
||||||
|
return {
|
||||||
|
key: lessonKey(lesson, group),
|
||||||
|
start: lesson.start,
|
||||||
|
end: lesson.end || bell?.end || null,
|
||||||
|
pair: bell?.pair ?? null,
|
||||||
|
subject: lesson.subject,
|
||||||
|
type: lesson.type,
|
||||||
|
teachers: people.teachers,
|
||||||
|
rooms,
|
||||||
|
roomMoved: Boolean(date && rooms !== people.rooms),
|
||||||
|
distance: people.distance,
|
||||||
|
condition: lesson.condition,
|
||||||
|
variant: lesson.variant,
|
||||||
|
subgroup: lesson.subgroups.find((s) => s.group === group)?.label || null,
|
||||||
|
groups: lesson.groups,
|
||||||
|
uncertain: status === "maybe",
|
||||||
|
raw: lesson.raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* days: one entry per date from `from`, with the lessons that take place that day.
|
||||||
|
* weekly: the Word rows by weekday, for when dates cannot be calculated.
|
||||||
|
*/
|
||||||
|
export function timetable({ file, schedule, calendar, group, from, days }) {
|
||||||
|
if (!schedule.groups.includes(group)) {
|
||||||
|
const error = new Error(`В документе нет группы ${group}.`);
|
||||||
|
error.status = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS);
|
||||||
|
const lessons = schedule.lessons.filter((l) => l.groups.includes(group));
|
||||||
|
const entries = lessons.map((lesson) => ({
|
||||||
|
doc: { id: file.id, schedule },
|
||||||
|
lesson,
|
||||||
|
...peopleAndRooms(lesson, schedule.academicYear),
|
||||||
|
}));
|
||||||
|
const view = semesterView(calendar, schedule, from);
|
||||||
|
const views = new Map();
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const date = addDays(from, i);
|
||||||
|
const week = view ? view.weeks[weekIndexOf(view, date)] || null : null;
|
||||||
|
const dayLessons = [];
|
||||||
|
if (view)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const status = occurrence(entry, date, calendar, views);
|
||||||
|
if (status === "yes" || status === "maybe")
|
||||||
|
dayLessons.push(lessonOut(entry.lesson, group, entry, date, status, calendar));
|
||||||
|
}
|
||||||
|
dayLessons.sort((a, b) => a.start.localeCompare(b.start));
|
||||||
|
out.push({
|
||||||
|
date,
|
||||||
|
weekday: weekday(date),
|
||||||
|
week: week ? { n: week.n, color: week.color } : null,
|
||||||
|
lessons: dayLessons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const weekly = [1, 2, 3, 4, 5, 6, 7]
|
||||||
|
.map((day) => ({
|
||||||
|
day,
|
||||||
|
lessons: entries
|
||||||
|
.filter((e) => e.lesson.day === day && !e.lesson.date)
|
||||||
|
.map((e) => lessonOut(e.lesson, group, e, null, "yes", calendar))
|
||||||
|
.sort((a, b) => a.start.localeCompare(b.start)),
|
||||||
|
}))
|
||||||
|
.filter((d) => d.lessons.length);
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
document: {
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
faculty: file.faculty || schedule.source.faculty || "",
|
||||||
|
course: file.course || schedule.source.course || "",
|
||||||
|
modified: file.modified || schedule.source.modified || null,
|
||||||
|
},
|
||||||
|
period: schedule.period,
|
||||||
|
semester: view?.semester || null,
|
||||||
|
note: view
|
||||||
|
? null
|
||||||
|
: "Семестр этого расписания не найден в таблице недель: даты не рассчитаны, смотрите строки Word (weekly).",
|
||||||
|
subgroups: subgroupsOf(schedule, group),
|
||||||
|
bells: calendar?.bells || [],
|
||||||
|
from,
|
||||||
|
days: out,
|
||||||
|
weekly,
|
||||||
|
notes: schedule.notes,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
skills-lock.json
Normal file
11
skills-lock.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"material-3": {
|
||||||
|
"source": "hamen/material-3-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/material-3/SKILL.md",
|
||||||
|
"computedHash": "72d56347fae33c9b1763e16d0bffb781da382aa3be316b9252dcd35fd050c6bc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
test/timetable.test.js
Normal file
53
test/timetable.test.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { parseDocx } from "../server/parser.js";
|
||||||
|
import { parseWeeksDocx, parseBellsDocx } from "../server/calendar.js";
|
||||||
|
import { mergeWeekTables } from "../server/source.js";
|
||||||
|
import { timetable } from "../server/timetable.js";
|
||||||
|
|
||||||
|
const fixture = (name) =>
|
||||||
|
readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
|
||||||
|
const calendar = {
|
||||||
|
...mergeWeekTables([parseWeeksDocx(fixture("weeks"))]),
|
||||||
|
bells: parseBellsDocx(fixture("bells")),
|
||||||
|
source: {},
|
||||||
|
};
|
||||||
|
const schedule = parseDocx(fixture("fef-23-ba"), { name: "23-ЛГ_23-БА.docx" });
|
||||||
|
const file = { id: "fef", name: "23-ЛГ_23-БА.docx", faculty: "ФЭФ", course: "4 курс" };
|
||||||
|
|
||||||
|
test("Расписание группы по датам: недели, пары, подгруппы, пустые выходные", () => {
|
||||||
|
const t = timetable({ file, schedule, calendar, group: "23-БА", from: "2026-10-16", days: 3 });
|
||||||
|
assert.equal(t.semester.label, "Осенний семестр 2026/27");
|
||||||
|
assert.deepEqual(t.days.map((d) => [d.date, d.weekday, d.week.n, d.week.color]), [
|
||||||
|
["2026-10-16", 5, 7, "white"],
|
||||||
|
["2026-10-17", 6, 7, "white"],
|
||||||
|
["2026-10-18", 7, 7, "white"],
|
||||||
|
]);
|
||||||
|
// Friday of week 7: "12-15н." and "16н." lessons are not on this date.
|
||||||
|
assert.deepEqual(
|
||||||
|
t.days[0].lessons.map((l) => [l.start, l.pair, l.subject]),
|
||||||
|
[
|
||||||
|
["08:30", 1, "Инновационный менеджмент"],
|
||||||
|
["10:05", 2, "PR-менеджмент"],
|
||||||
|
["11:45", 3, "Бизнес-анализ"],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(t.days[1].lessons.length, 0);
|
||||||
|
assert.ok(t.weekly.length >= 5, "строки Word по дням недели доступны всегда");
|
||||||
|
const lg = timetable({ file, schedule, calendar, group: "23-ЛГ", from: "2026-10-12", days: 7 });
|
||||||
|
assert.deepEqual(lg.subgroups, ["Подгруппа 1", "Подгруппа 2"]);
|
||||||
|
assert.ok(lg.days.flatMap((d) => d.lessons).some((l) => l.subgroup === "Подгруппа 2"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Без семестра в таблице недель даты не угадываются", () => {
|
||||||
|
const spring = { ...schedule, period: "Весенний семестр" };
|
||||||
|
const t = timetable({ file, schedule: spring, calendar, group: "23-БА", from: "2027-03-01", days: 7 });
|
||||||
|
assert.equal(t.semester, null);
|
||||||
|
assert.ok(t.note);
|
||||||
|
assert.ok(t.days.every((d) => d.lessons.length === 0 && d.week === null));
|
||||||
|
assert.throws(
|
||||||
|
() => timetable({ file, schedule, calendar, group: "99-ХХ", from: "2026-10-16", days: 1 }),
|
||||||
|
/нет группы/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user