Compare commits
No commits in common. "builds" and "main" have entirely different histories.
3
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Keep Windows batch scripts usable when checked out on Windows.
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
296
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
name: Build
|
||||||
|
|
||||||
|
# Packages are published to the `builds` branch instead of to a GitHub
|
||||||
|
# Release, so everything the installers and the in-app updater fetch comes
|
||||||
|
# straight from the repository and always matches what is on main.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
# Nothing to rebuild when only documentation changed.
|
||||||
|
paths-ignore:
|
||||||
|
- "README.md"
|
||||||
|
- "LICENSE"
|
||||||
|
- ".gitignore"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux:
|
||||||
|
name: Linux AppImage
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# ProGuard and jlink need a JDK that ships jmods; Temurin 21 does.
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
|
with:
|
||||||
|
build-root-directory: android
|
||||||
|
|
||||||
|
- name: Answer checks
|
||||||
|
run: |
|
||||||
|
chmod +x android/gradlew
|
||||||
|
cd android
|
||||||
|
./gradlew :desktop:test --no-daemon
|
||||||
|
|
||||||
|
- name: Build AppImage
|
||||||
|
env:
|
||||||
|
COMMIT: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
export PACKAGE_JDK="$JAVA_HOME"
|
||||||
|
chmod +x scripts/bundle-linux.sh
|
||||||
|
./scripts/bundle-linux.sh
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: graduately-linux
|
||||||
|
path: dist/graduately-linux-*.AppImage
|
||||||
|
|
||||||
|
windows:
|
||||||
|
name: Windows zip
|
||||||
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
|
with:
|
||||||
|
build-root-directory: android
|
||||||
|
|
||||||
|
- name: Build zip
|
||||||
|
env:
|
||||||
|
COMMIT: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
export PACKAGE_JDK="$JAVA_HOME"
|
||||||
|
bash scripts/bundle-windows.sh
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: graduately-windows
|
||||||
|
path: dist/graduately-windows-x64.zip
|
||||||
|
|
||||||
|
macos:
|
||||||
|
name: macOS app
|
||||||
|
runs-on: macos-26
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Developer ID lives in repo secrets. Without it the bundle is only
|
||||||
|
# ad-hoc signed and Gatekeeper still blocks downloaded copies.
|
||||||
|
# Do not use `secrets` in `if:` — GitHub rejects the whole workflow.
|
||||||
|
- name: Import Developer ID certificate
|
||||||
|
env:
|
||||||
|
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||||
|
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MACOS_CERTIFICATE:-}" ]; then
|
||||||
|
echo "No Developer ID certificate; the bundle will be ad-hoc signed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cert="$RUNNER_TEMP/certificate.p12"
|
||||||
|
keychain="$RUNNER_TEMP/signing.keychain-db"
|
||||||
|
pass="$(openssl rand -base64 32)"
|
||||||
|
echo "$MACOS_CERTIFICATE" | base64 --decode > "$cert"
|
||||||
|
security create-keychain -p "$pass" "$keychain"
|
||||||
|
security set-keychain-settings -lut 21600 "$keychain"
|
||||||
|
security unlock-keychain -p "$pass" "$keychain"
|
||||||
|
security import "$cert" -k "$keychain" \
|
||||||
|
-P "$MACOS_CERTIFICATE_PASSWORD" \
|
||||||
|
-T /usr/bin/codesign -T /usr/bin/security
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -s \
|
||||||
|
-k "$pass" "$keychain"
|
||||||
|
security list-keychains -d user -s "$keychain" \
|
||||||
|
$(security list-keychains -d user | tr -d '"')
|
||||||
|
rm -f "$cert"
|
||||||
|
|
||||||
|
- name: Build Swift .app zip
|
||||||
|
env:
|
||||||
|
COMMIT: ${{ github.sha }}
|
||||||
|
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
|
||||||
|
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/bundle-macos-swift.sh
|
||||||
|
./scripts/bundle-macos-swift.sh
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: graduately-macos
|
||||||
|
path: dist/graduately-macos-*.zip
|
||||||
|
|
||||||
|
android:
|
||||||
|
name: Android APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
# v3 defaults to the retired `tools` package, which sdkmanager no longer
|
||||||
|
# serves. Request only packages that still exist.
|
||||||
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
|
||||||
|
- name: Install Android SDK platform
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sdkmanager --install "build-tools;36.0.0"
|
||||||
|
if ! sdkmanager --install "platforms;android-37.0"; then
|
||||||
|
sdkmanager --install "platforms;android-37"
|
||||||
|
fi
|
||||||
|
if [ -d "$ANDROID_HOME/platforms/android-37.0" ] && [ ! -e "$ANDROID_HOME/platforms/android-37" ]; then
|
||||||
|
ln -sfn android-37.0 "$ANDROID_HOME/platforms/android-37"
|
||||||
|
fi
|
||||||
|
ls "$ANDROID_HOME/platforms"
|
||||||
|
|
||||||
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
|
with:
|
||||||
|
build-root-directory: android
|
||||||
|
|
||||||
|
- name: Build content and APK
|
||||||
|
env:
|
||||||
|
COMMIT: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
python3 scripts/build-content.py
|
||||||
|
chmod +x android/gradlew
|
||||||
|
cd android
|
||||||
|
./gradlew :app:assembleRelease --no-daemon
|
||||||
|
mkdir -p ../dist-android
|
||||||
|
cp app/build/outputs/apk/release/app-release.apk \
|
||||||
|
../dist-android/graduately-android.apk
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: graduately-android
|
||||||
|
path: dist-android/graduately-android.apk
|
||||||
|
|
||||||
|
ios:
|
||||||
|
name: iOS IPA
|
||||||
|
# The app targets iOS 26; this image ships Xcode 26 as the default.
|
||||||
|
runs-on: macos-26
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Answer checks
|
||||||
|
env:
|
||||||
|
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/test-answers-swift.sh
|
||||||
|
./scripts/test-answers-swift.sh
|
||||||
|
|
||||||
|
- name: Build content and IPA
|
||||||
|
env:
|
||||||
|
COMMIT: ${{ github.sha }}
|
||||||
|
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/bundle-ios.sh
|
||||||
|
./scripts/bundle-ios.sh
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: graduately-ios
|
||||||
|
path: dist-ios/graduately-ios.ipa
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish to builds branch
|
||||||
|
needs: [linux, windows, macos, android, ios]
|
||||||
|
# One broken platform must not freeze the others: publish whatever was
|
||||||
|
# built and keep the previous package for anything that failed.
|
||||||
|
if: ${{ !cancelled() && contains(needs.*.result, 'success') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Start from the previous builds branch
|
||||||
|
run: |
|
||||||
|
mkdir -p staged
|
||||||
|
if git fetch --depth 1 origin builds 2>/dev/null; then
|
||||||
|
git archive FETCH_HEAD | tar -x -C staged
|
||||||
|
rm -f staged/VERSION
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: fresh
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Collect installers
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cp -f fresh/* staged/ 2>/dev/null || true
|
||||||
|
rm -f staged/graduately-macos-swift-*.zip staged/VERSION-macos-swift
|
||||||
|
chmod +x installers/pack-installer-zips.sh
|
||||||
|
./installers/pack-installer-zips.sh
|
||||||
|
cp installers/graduately-installer-linux.sh staged/
|
||||||
|
cp installers/graduately-installer-windows.cmd staged/
|
||||||
|
cp installers/graduately-installer-*.zip staged/
|
||||||
|
# The updaters download this to see whether they are behind.
|
||||||
|
echo "${{ github.sha }}" > staged/VERSION
|
||||||
|
# Per-platform markers name the commit whose package is really
|
||||||
|
# here, so a platform that failed this run is not offered as new.
|
||||||
|
[ -f fresh/graduately-android.apk ] && echo "${{ github.sha }}" > staged/VERSION-android
|
||||||
|
[ -f fresh/graduately-ios.ipa ] && echo "${{ github.sha }}" > staged/VERSION-ios
|
||||||
|
ls fresh/graduately-macos-*.zip >/dev/null 2>&1 && echo "${{ github.sha }}" > staged/VERSION-macos
|
||||||
|
[ -f fresh/graduately-windows-x64.zip ] && echo "${{ github.sha }}" > staged/VERSION-windows
|
||||||
|
[ -f fresh/graduately-linux-x86_64.AppImage ] && echo "${{ github.sha }}" > staged/VERSION-linux
|
||||||
|
# Compatibility aliases so older installers/updaters still find packages.
|
||||||
|
[ -f staged/graduately-macos-arm64.zip ] && cp -f staged/graduately-macos-arm64.zip staged/maturita-macos-arm64.zip
|
||||||
|
[ -f staged/graduately-macos-x86_64.zip ] && cp -f staged/graduately-macos-x86_64.zip staged/maturita-macos-x86_64.zip
|
||||||
|
[ -f staged/graduately-windows-x64.zip ] && cp -f staged/graduately-windows-x64.zip staged/maturita-windows-x64.zip
|
||||||
|
[ -f staged/graduately-linux-x86_64.AppImage ] && cp -f staged/graduately-linux-x86_64.AppImage staged/maturita-linux-x86_64.AppImage
|
||||||
|
[ -f staged/graduately-android.apk ] && cp -f staged/graduately-android.apk staged/maturita-android.apk
|
||||||
|
[ -f staged/graduately-ios.ipa ] && cp -f staged/graduately-ios.ipa staged/maturita-ios.ipa
|
||||||
|
ls fresh staged
|
||||||
|
|
||||||
|
# The branch is rewritten from scratch every time rather than added to,
|
||||||
|
# so the repository keeps one copy of the packages instead of a new
|
||||||
|
# ~70 MB set per commit.
|
||||||
|
- name: Push to builds branch
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
rm -rf .git/index.lock
|
||||||
|
git checkout --orphan builds
|
||||||
|
git rm -rf --cached . >/dev/null
|
||||||
|
find . -maxdepth 1 ! -name . ! -name .git ! -name staged \
|
||||||
|
-exec rm -rf {} +
|
||||||
|
mv staged/* .
|
||||||
|
rmdir staged
|
||||||
|
ls -la
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "Build from ${{ github.sha }}"
|
||||||
|
git push -f origin builds
|
||||||
53
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Binaries and build trees
|
||||||
|
/maturita
|
||||||
|
/maturita.exe
|
||||||
|
/graduately
|
||||||
|
/graduately.exe
|
||||||
|
/build/
|
||||||
|
/dist/
|
||||||
|
/progress/
|
||||||
|
*.o
|
||||||
|
*.d
|
||||||
|
*.res
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Generated icon copies (source of truth is data/share/icons)
|
||||||
|
/assets/icons/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
cmake-build-*/
|
||||||
|
*.dSYM/
|
||||||
|
|
||||||
|
# Local notes
|
||||||
|
error.txt
|
||||||
|
error.log
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/.gradle/
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/desktop/build/
|
||||||
|
android/local.properties
|
||||||
|
android/.idea/
|
||||||
|
android/*.iml
|
||||||
|
android/.cxx/
|
||||||
|
android/.local-sdk/
|
||||||
|
android/app/src/main/assets/content.json
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
ios/DerivedData/
|
||||||
|
ios/build/
|
||||||
|
ios/Payload/
|
||||||
|
ios/**/xcuserdata/
|
||||||
|
|
||||||
|
# native macOS Swift
|
||||||
|
macos/DerivedData/
|
||||||
|
macos/build/
|
||||||
|
macos/**/xcuserdata/
|
||||||
|
|
||||||
|
# Xcode workspace user state
|
||||||
|
*.xcworkspace/xcuserdata/
|
||||||
10
Graduately.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:ios/Graduately.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:macos/Graduately.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
232
LICENSE
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
sprechen.c
|
||||||
|
Copyright (C) 2026 dvorakmortonxd
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
sprechen.c Copyright (C) 2026 dvorakmortonxd
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
63
Makefile
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Every app reads the lessons from content/ (built into content.json).
|
||||||
|
# Android, Linux, Windows: Kotlin + Compose in android/ (shared code in android/shared)
|
||||||
|
# iOS, macOS: Swift + SwiftUI in ios/ and macos/
|
||||||
|
#
|
||||||
|
# Published builds pass COMMIT (the CI workflow exports it); the updaters
|
||||||
|
# compare it against the build in the repository. Without it a build stays
|
||||||
|
# "dev" and never offers an update.
|
||||||
|
|
||||||
|
GRADLE = cd android && ./gradlew
|
||||||
|
|
||||||
|
all: content
|
||||||
|
|
||||||
|
content:
|
||||||
|
python3 scripts/build-content.py
|
||||||
|
|
||||||
|
# Runs the desktop app from source on this machine (Linux, Windows or macOS).
|
||||||
|
run:
|
||||||
|
$(GRADLE) :desktop:run
|
||||||
|
|
||||||
|
# Answer checks shared by Kotlin and Swift (tests/answers.json), plus
|
||||||
|
# progress migration and import of GTK progress.
|
||||||
|
test:
|
||||||
|
$(GRADLE) :desktop:test
|
||||||
|
@if command -v xcrun >/dev/null 2>&1; then \
|
||||||
|
chmod +x scripts/test-answers-swift.sh && ./scripts/test-answers-swift.sh; \
|
||||||
|
else \
|
||||||
|
echo "Swift checks skipped (no Xcode)"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
android:
|
||||||
|
$(GRADLE) :app:assembleRelease
|
||||||
|
|
||||||
|
linux:
|
||||||
|
chmod +x scripts/bundle-linux.sh
|
||||||
|
./scripts/bundle-linux.sh
|
||||||
|
|
||||||
|
windows:
|
||||||
|
bash scripts/bundle-windows.sh
|
||||||
|
|
||||||
|
ios:
|
||||||
|
chmod +x scripts/bundle-ios.sh
|
||||||
|
./scripts/bundle-ios.sh
|
||||||
|
|
||||||
|
macos:
|
||||||
|
chmod +x scripts/bundle-macos-swift.sh
|
||||||
|
./scripts/bundle-macos-swift.sh
|
||||||
|
|
||||||
|
# Browsers render .command/.sh/.cmd as text/plain. Rebuild these zips after
|
||||||
|
# changing a script so the README download links stay in sync. The macOS zip
|
||||||
|
# is a double-clickable .app — no Terminal, no chmod.
|
||||||
|
installer-zips:
|
||||||
|
chmod +x installers/pack-installer-zips.sh
|
||||||
|
./installers/pack-installer-zips.sh
|
||||||
|
|
||||||
|
smoke:
|
||||||
|
chmod +x scripts/smoke-platforms.sh
|
||||||
|
./scripts/smoke-platforms.sh
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf build dist dist-android dist-ios
|
||||||
|
$(GRADLE) clean
|
||||||
|
|
||||||
|
.PHONY: all content run test android linux windows ios macos installer-zips smoke clean
|
||||||
398
README.md
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
# Graduately
|
||||||
|
|
||||||
|
Vzdělávací aplikace k přípravě na maturitu (repo `Graduately`). / An educational app for the Czech maturita exam (repo `Graduately`).
|
||||||
|
|
||||||
|
**[Česky](#česky)** · **[English](#english)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Česky
|
||||||
|
|
||||||
|
Rozhraní je v češtině a angličtině. Všechny předměty v seznamu níže jsou
|
||||||
|
otevřené. Aplikace se po instalaci aktualizuje sama.
|
||||||
|
|
||||||
|
| Úvod | Němčina | Neue Freunde |
|
||||||
|
| ---- | -------- | ------------ |
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
### Instalace
|
||||||
|
|
||||||
|
Žádné releasy ani čísla verzí nejsou. CI po každém pushi na `main` uloží
|
||||||
|
balíčky do větve [`builds`](https://github.com/pepaskopec-blip/Graduately/tree/builds).
|
||||||
|
Instalátor samotnou aplikaci neobsahuje — při spuštění si dohledá aktuální
|
||||||
|
commit větve `builds` a stáhne ten soubor, ne kešovanou kopii podle názvu
|
||||||
|
větve.
|
||||||
|
|
||||||
|
**Stáhni zip, rozbal ho a otevři instalátor.** Nic víc (žádný Terminál):
|
||||||
|
|
||||||
|
| Platforma | Soubor | Jak spustit |
|
||||||
|
| --------- | ------ | ----------- |
|
||||||
|
| macOS (Apple Silicon) | [graduately-installer-macos.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-macos.zip) | Rozbalit, pravý klik na **Nainstalovat Graduately** → **Otevřít** |
|
||||||
|
| Windows (x64) | [graduately-installer-windows.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-windows.zip) | Rozbalit a dvojklik; u SmartScreenu **Další informace → Přesto spustit** |
|
||||||
|
| Linux (x86_64) | [graduately-installer-linux.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-linux.zip) | Rozbalit a dvojklik na instalátor |
|
||||||
|
| Android (8+) | [graduately-installer-android.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-android.zip) | Rozbalit, otevřít **Stahnout Graduately.html** a nainstalovat APK (neznámé zdroje) |
|
||||||
|
| iOS (26+) | [graduately-installer-ios.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-ios.zip) | Rozbalit, otevřít **Jak nainstalovat Graduately.html**. Nejjednodušší: Xcode na Macu, nebo IPA přes AltStore |
|
||||||
|
|
||||||
|
Kam se to nainstaluje: `/Applications` nebo `~/Applications` (macOS),
|
||||||
|
`%LOCALAPPDATA%\Programs\Graduately` (Windows), `~/Applications` (Linux).
|
||||||
|
|
||||||
|
Buildy zatím nejsou notarizované (chybí Apple Developer ID), proto je při
|
||||||
|
**prvním** spuštění potřeba výše uvedený krok navíc. Aplikace se pak udržuje
|
||||||
|
sama: po startu porovná svůj commit se souborem `VERSION` ve větvi `builds`
|
||||||
|
a aktualizaci nabídne v pruhu nahoře. Ruční kontrola je
|
||||||
|
v **Nastavení → Aktualizace**.
|
||||||
|
|
||||||
|
#### Bez instalátoru
|
||||||
|
|
||||||
|
Na větvi [`builds`](https://github.com/pepaskopec-blip/Graduately/tree/builds)
|
||||||
|
jsou i hotové balíčky. Odkazy podle názvu větve umí CDN kešovat, proto je
|
||||||
|
spolehlivější instalátor výše, nebo stažení přímo z té stránky větve:
|
||||||
|
|
||||||
|
- [graduately-macos-arm64.zip](https://github.com/pepaskopec-blip/Graduately/raw/builds/graduately-macos-arm64.zip)
|
||||||
|
- [graduately-windows-x64.zip](https://github.com/pepaskopec-blip/Graduately/raw/builds/graduately-windows-x64.zip)
|
||||||
|
- [graduately-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/Graduately/raw/builds/graduately-linux-x86_64.AppImage)
|
||||||
|
- [graduately-android.apk](https://github.com/pepaskopec-blip/Graduately/raw/refs/heads/builds/graduately-android.apk)
|
||||||
|
- [graduately-ios.ipa](https://github.com/pepaskopec-blip/Graduately/raw/refs/heads/builds/graduately-ios.ipa)
|
||||||
|
|
||||||
|
Na Linuxu a Windows se postup a nastavení ukládají do `progress/` vedle
|
||||||
|
AppImage / ve složce aplikace; postup ze starší GTK verze se při prvním
|
||||||
|
spuštění převezme. Na Androidu, iOS a macOS do úložiště aplikace (stejné
|
||||||
|
statistiky, témata a jazyk).
|
||||||
|
|
||||||
|
Intel Mac, jiná architektura nebo úpravy kódu → [sestavení ze zdroje](#sestavení-ze-zdroje).
|
||||||
|
|
||||||
|
### Co v aplikaci je
|
||||||
|
|
||||||
|
- **Deutsch** — učebnice (Neue Freunde, Aus aller Welt, Bei uns zu Hause)
|
||||||
|
a 4 ročníky, 24 lekcí od A1 k B1: poslech, čtení, psaní, doplňování a kvízy
|
||||||
|
- **Správa počítačových sítí** — 4 ročníky: 1. ročník má 27 lekcí
|
||||||
|
(adresy, modely, přepínače a bezdrát), ročníky 2–4 po 8 lekcích
|
||||||
|
(VLAN, směrování, firewall, návrh a maturita)
|
||||||
|
- **Technické vybavení** — 4 ročníky: 1. ročník má 29 lekcí
|
||||||
|
(architektura, data, skříň, deska, karty, disky a vstup),
|
||||||
|
ročníky 2–4 po 8 lekcích (monitory, sestavení, RAID, servery, údržba)
|
||||||
|
- **Technická grafika** — 4 ročníky AutoCADu, v každém 8 lekcí:
|
||||||
|
výkres a souřadnice, úpravy a kóty, pohledy a tisk, 3D a maturitní zadání
|
||||||
|
- **Prezentační grafika** — 4 ročníky, v každém 8 lekcí: Photoshop od pixelu
|
||||||
|
po plakát, Premiere Pro a ve čtvrtém ročníku Illustrator, After Effects
|
||||||
|
a InDesign
|
||||||
|
- **Programové vybavení** — 4 ročníky, v každém 8 lekcí: Windows a licence,
|
||||||
|
Word a Excel, Access a MySQL, pak WordPress, Power BI, dávkové soubory a zásady
|
||||||
|
- **Programování** — 4 ročníky C# po vzoru W3Schools. U každé lekce se kód
|
||||||
|
píše a spouští vedle výkladu, od výpisu po třídy, LINQ a async
|
||||||
|
- **Český jazyk a literatura** — literatura (4 ročníky, 32 lekcí podle osnov
|
||||||
|
středních škol), mluvnice (20 cvičení) a maturitní četba
|
||||||
|
(82 knih: zápisky, kvíz, sestavení děje)
|
||||||
|
- **Matematika** — opakování základní školy a 4 ročníky, 40 lekcí;
|
||||||
|
příklady se píšou a kontrolují, u geometrie jde i rýsovat na mřížku
|
||||||
|
- **Fyzika** — 4 ročníky, 32 lekcí podle osnov středních škol
|
||||||
|
(mechanika, teplo a vlny, elektřina a magnetismus, optika až astrofyzika)
|
||||||
|
- **Základy přírodních věd** — chemie a biologie, v každé 8 lekcí
|
||||||
|
(od atomu a buňky po biochemii a ekologii) s výkladem a kvízem
|
||||||
|
- **Občanská nauka** — 1. ročník, 17 lekcí (člověk v lidském společenství);
|
||||||
|
2. ročník, 16 lekcí (člověk jako občan); 3. ročník, 16 lekcí (člověk a právo);
|
||||||
|
4. ročník, 16 lekcí (hospodářství, svět a filozofie)
|
||||||
|
- **Angličtina** — 4 ročníky, 32 lekcí podle osnov gymnázia (A2 až maturita):
|
||||||
|
poslech, čtení, psaní, doplňování, kvízy a ročníkové testy
|
||||||
|
- statistiky, hledání (lupa nebo `Cmd/Ctrl+K`), 10 témat, tmavý/světlý režim, čeština/angličtina
|
||||||
|
- ukončení: `Ctrl/Cmd+Q` nebo `Alt+F4`, zpět: `Esc`
|
||||||
|
|
||||||
|
### Použití
|
||||||
|
|
||||||
|
1. **Úvod** ukáže postup a mřížku předmětů. Každý předmět spustí svou cestu.
|
||||||
|
2. U němčiny je učebnice (Neue Freunde, Aus aller Welt, Bei uns zu Hause) a čtyři ročníky. Ostatní předměty mají ročníky, lekce nebo knihy.
|
||||||
|
3. Z cesty se otevře výklad, poslech, čtení, psaní nebo kvíz.
|
||||||
|
4. **Statistiky**, **Nastavení** a **Hledat** jsou v postranním panelu na Macu. Na telefonu a v desktopové aplikaci jsou na záložkách, hledání navíc ikonou lupy. Zkratka je `Cmd/Ctrl+K`.
|
||||||
|
|
||||||
|
### Sestavení ze zdroje
|
||||||
|
|
||||||
|
Dvě kódové základny, jeden obsah (`content/`) a společné testy (`tests/`):
|
||||||
|
|
||||||
|
- **Kotlin + Compose** (`android/`): Android, Linux a Windows. Obrazovky,
|
||||||
|
cvičení a kontrola odpovědí jsou v `android/shared/`, platformy se liší jen
|
||||||
|
vstupním bodem (`android/app`, `android/desktop`).
|
||||||
|
- **Swift + SwiftUI** (`ios/`, `macos/`): iPhone a Mac (Xcode 26, macOS 26+).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/pepaskopec-blip/Graduately.git
|
||||||
|
cd Graduately
|
||||||
|
make test # kontrola odpovědí v Kotlinu (a ve Swiftu, je-li Xcode)
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux, Windows (i Mac pro vývoj) — stačí JDK 17+ a Python 3:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run # spustí desktopovou aplikaci ze zdroje
|
||||||
|
make linux # dist/graduately-linux-x86_64.AppImage
|
||||||
|
make windows # dist/graduately-windows-x64.zip (v Git Bash)
|
||||||
|
make smoke # instalátory, obsah, testy, dostupnost builds
|
||||||
|
```
|
||||||
|
|
||||||
|
Na balení (`make linux` / `make windows`) je potřeba JDK s `jmods`, např.
|
||||||
|
Temurin 21; cestu k němu předejte v `PACKAGE_JDK`. ProGuard zmenší balíček
|
||||||
|
pod limit GitHubu 100 MB.
|
||||||
|
|
||||||
|
Na Macu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open Graduately.xcworkspace # scheme Graduately z macos/Graduately.xcodeproj
|
||||||
|
# nebo: make macos # zip v dist/graduately-macos-arm64.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Na macOS `make macos` automaticky vloží aktuální Git commit, aby fungovala
|
||||||
|
kontrola aktualizací. Při balení ze zdrojů bez `.git` zadejte
|
||||||
|
`COMMIT=<zdrojový-commit> make macos`.
|
||||||
|
|
||||||
|
Aby stažená aplikace na macOS šla otevřít bez potvrzení v Nastavení, CI
|
||||||
|
potřebuje **Apple Developer Program** a GitHub Secrets:
|
||||||
|
|
||||||
|
- `MACOS_CERTIFICATE` — Developer ID Application `.p12` v base64
|
||||||
|
- `MACOS_CERTIFICATE_PASSWORD`
|
||||||
|
- `APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER` — notarizace
|
||||||
|
(nebo `APPLE_ID` + `APPLE_APP_SPECIFIC_PASSWORD` + `APPLE_TEAM_ID`)
|
||||||
|
|
||||||
|
Bez toho zůstane jen ad-hoc podpis a Gatekeeper stažený build zablokuje.
|
||||||
|
|
||||||
|
### Struktura
|
||||||
|
|
||||||
|
```
|
||||||
|
content/ texty lekcí, cvičení a knih v Markdownu (viz content/README.md)
|
||||||
|
android/shared/ společný Kotlin: obrazovky, cvičení, kontrola odpovědí, postup
|
||||||
|
android/app/ Android (vstupní bod, aktualizace APK, předčítání)
|
||||||
|
android/desktop/ Linux a Windows (okno, aktualizace, předčítání, import z GTK)
|
||||||
|
ios/ SwiftUI pro iPhone
|
||||||
|
macos/ SwiftUI pro Mac (sdílí kód s ios/)
|
||||||
|
tests/ společné testy kontroly odpovědí (answers.json) + Swift harness
|
||||||
|
data/share/ ikony a .desktop soubor
|
||||||
|
assets/ zdrojová ikona a screenshoty
|
||||||
|
installers/ instalátory, které stáhnou aktuální build
|
||||||
|
scripts/ build-content.py, balení AppImage, zip, .app, IPA a smoke test
|
||||||
|
.github/workflows/ CI: push na main → větev builds
|
||||||
|
```
|
||||||
|
|
||||||
|
Android ze zdroje (JDK 17 + Android SDK):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew :app:assembleRelease # obsah z content/ se sestaví sám
|
||||||
|
# APK: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
nebo z kořene `make android`.
|
||||||
|
|
||||||
|
APK se podepisuje klíčem `android/keystore/sideload.jks` (není tajný – jde o
|
||||||
|
sideload, ne o Google Play), aby aktualizace z aplikace šla nainstalovat přes
|
||||||
|
předchozí build. Vlastní klíč lze dodat přes `MATURITA_KEYSTORE` a
|
||||||
|
`MATURITA_KEYSTORE_PASSWORD`. Buildy `android-5` a starší mají jiný podpis –
|
||||||
|
jednou odinstalujte a nainstalujte znovu.
|
||||||
|
|
||||||
|
iOS ze zdroje (Xcode 26, iOS 26+ / Liquid Glass):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open Graduately.xcworkspace # v kořeni repozitáře
|
||||||
|
# Simulátor: vyber iPhone a Run, nic dalšího není potřeba (obsah se
|
||||||
|
# sestaví z content/ při buildu). Na iPhone: v Signing vyber svůj Team.
|
||||||
|
# nebo: make ios # unsigned IPA v dist-ios/graduately-ios.ipa
|
||||||
|
```
|
||||||
|
|
||||||
|
Workspace odkazuje na `ios/Graduately.xcodeproj` a `macos/Graduately.xcodeproj`.
|
||||||
|
Projekty nepřesouvejte – build čte `../content` a `../scripts`.
|
||||||
|
|
||||||
|
Licence: [GPL-3.0](LICENSE).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
The UI is Czech and English. Every subject listed below is open. After
|
||||||
|
install the app updates itself.
|
||||||
|
|
||||||
|
| Welcome | German | Neue Freunde |
|
||||||
|
| ------- | ------ | ----------- |
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
There are no releases and no version numbers. CI writes packages to the
|
||||||
|
[`builds`](https://github.com/pepaskopec-blip/Graduately/tree/builds) branch
|
||||||
|
whenever `main` changes. The installer carries no app of its own — it fetches
|
||||||
|
the current `builds` commit when you run it, not a CDN-cached copy of the
|
||||||
|
branch-named file.
|
||||||
|
|
||||||
|
**Download the zip, unzip it, and open the installer.** No Terminal:
|
||||||
|
|
||||||
|
| Platform | File | How to run |
|
||||||
|
| -------- | ---- | ---------- |
|
||||||
|
| macOS (Apple Silicon) | [graduately-installer-macos.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-macos.zip) | Unzip, right-click **Nainstalovat Graduately** → **Open** |
|
||||||
|
| Windows (x64) | [graduately-installer-windows.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-windows.zip) | Unzip and double-click; if SmartScreen appears, **More info → Run anyway** |
|
||||||
|
| Linux (x86_64) | [graduately-installer-linux.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-linux.zip) | Unzip and double-click the installer |
|
||||||
|
| Android (8+) | [graduately-installer-android.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-android.zip) | Unzip, open **Stahnout Graduately.html**, then install the APK (unknown sources) |
|
||||||
|
| iOS (26+) | [graduately-installer-ios.zip](https://github.com/pepaskopec-blip/Graduately/raw/main/installers/graduately-installer-ios.zip) | Unzip, open **Jak nainstalovat Graduately.html**. Easiest: Xcode on a Mac, or the IPA via AltStore |
|
||||||
|
|
||||||
|
Install locations: `/Applications` or `~/Applications` (macOS),
|
||||||
|
`%LOCALAPPDATA%\Programs\Graduately` (Windows), `~/Applications` (Linux).
|
||||||
|
|
||||||
|
The builds are not notarized yet (no Apple Developer ID), so the first
|
||||||
|
launch still needs the extra step above. After that the app stays current:
|
||||||
|
it compares its commit to `VERSION` on `builds` and offers an update in a
|
||||||
|
banner. Manual check: **Settings → Updates**.
|
||||||
|
|
||||||
|
#### Packages without the installer
|
||||||
|
|
||||||
|
The same files live on
|
||||||
|
[`builds`](https://github.com/pepaskopec-blip/Graduately/tree/builds).
|
||||||
|
Branch-named raw URLs can be cached by the CDN, so prefer the installer
|
||||||
|
above, or download from that branch page:
|
||||||
|
|
||||||
|
- [graduately-macos-arm64.zip](https://github.com/pepaskopec-blip/Graduately/raw/builds/graduately-macos-arm64.zip)
|
||||||
|
- [graduately-windows-x64.zip](https://github.com/pepaskopec-blip/Graduately/raw/builds/graduately-windows-x64.zip)
|
||||||
|
- [graduately-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/Graduately/raw/builds/graduately-linux-x86_64.AppImage)
|
||||||
|
- [graduately-android.apk](https://github.com/pepaskopec-blip/Graduately/raw/refs/heads/builds/graduately-android.apk)
|
||||||
|
- [graduately-ios.ipa](https://github.com/pepaskopec-blip/Graduately/raw/refs/heads/builds/graduately-ios.ipa)
|
||||||
|
|
||||||
|
On Linux and Windows, progress and settings live in `progress/` next to the
|
||||||
|
AppImage / in the app folder; progress from the older GTK build is taken over
|
||||||
|
on first launch. On Android, iOS and macOS they stay in app storage (same
|
||||||
|
stats, themes, language).
|
||||||
|
|
||||||
|
Intel Mac, another architecture, or hacking on the code →
|
||||||
|
[build from source](#build-from-source).
|
||||||
|
|
||||||
|
### What’s inside
|
||||||
|
|
||||||
|
- **Deutsch** — the textbook (Neue Freunde, Aus aller Welt, Bei uns zu Hause)
|
||||||
|
plus 4 years, 24 lessons from A1 to B1: listening, reading, writing, gap-fill and quizzes
|
||||||
|
- **Computer networks** — 4 years: year 1 has 27 lessons
|
||||||
|
(addresses, models, switches and wireless), years 2–4 have 8 lessons each
|
||||||
|
(VLANs, routing, firewalls, design and the exam)
|
||||||
|
- **Computer hardware** — 4 years: year 1 has 29 lessons
|
||||||
|
(architecture, data, case, board, cards, drives and input),
|
||||||
|
years 2–4 have 8 lessons each (displays, assembly, RAID, servers, maintenance)
|
||||||
|
- **Technical graphics** — 4 years of AutoCAD, 8 lessons each:
|
||||||
|
the sheet and coordinates, editing and dimensions, views and plotting, 3D and the exam drawing
|
||||||
|
- **Presentation graphics** — 4 years, 8 lessons each: Photoshop from the pixel
|
||||||
|
to a poster, Premiere Pro, and in year 4 Illustrator, After Effects and InDesign
|
||||||
|
- **Software** — 4 years, 8 lessons each: Windows and licences,
|
||||||
|
Word and Excel, Access and MySQL, then WordPress, Power BI, batch files and policy
|
||||||
|
- **Programming** — 4 years of C# in the style of W3Schools. Each lesson has
|
||||||
|
code you edit and run beside the explanation, from output to classes, LINQ and async
|
||||||
|
- **Czech language** — literature (4 years, 32 lessons from the secondary-school
|
||||||
|
syllabus), grammar (20 exercises) and required reading
|
||||||
|
(82 books: notes, quiz, plot ordering)
|
||||||
|
- **Mathematics** — elementary-school review and 4 years, 40 lessons;
|
||||||
|
examples are typed and checked, and some geometry is drawn on a grid
|
||||||
|
- **Physics** — 4 years, 32 lessons from the secondary-school syllabus
|
||||||
|
(mechanics, heat and waves, electricity and magnetism, optics through astrophysics)
|
||||||
|
- **Natural sciences** — chemistry and biology, eight lessons each
|
||||||
|
(from the atom and the cell to biochemistry and ecology), with an overview and a quiz
|
||||||
|
- **Civics** — year 1, 17 lessons (the individual and society);
|
||||||
|
year 2, 16 lessons (the citizen in a democratic state);
|
||||||
|
year 3, 16 lessons (the individual and the law);
|
||||||
|
year 4, 16 lessons (the economy, the world and philosophy)
|
||||||
|
- **English** — 4 years, 32 lessons from the gymnasium syllabus (A2 through
|
||||||
|
the maturita): listening, reading, writing, gap-fill, quizzes and year tests
|
||||||
|
- statistics, search (magnifier or `Cmd/Ctrl+K`), 10 palettes, dark/light mode, Czech/English
|
||||||
|
- quit with `Ctrl/Cmd+Q` or `Alt+F4`, back with `Esc`
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
1. **Welcome** shows progress and the subject grid. A subject starts its path.
|
||||||
|
2. German has the textbook (Neue Freunde, Aus aller Welt, Bei uns zu Hause) and four years. The other subjects have years, lessons or books.
|
||||||
|
3. A path opens an overview, listening, reading, writing or a quiz.
|
||||||
|
4. **Statistics**, **Settings** and **Search** sit in the sidebar on Mac. On the phone and the desktop app they are tabs, with search also on the magnifier. The shortcut is `Cmd/Ctrl+K`.
|
||||||
|
|
||||||
|
### Build from source
|
||||||
|
|
||||||
|
Two code bases, one content tree (`content/`) and shared tests (`tests/`):
|
||||||
|
|
||||||
|
- **Kotlin + Compose** (`android/`): Android, Linux and Windows. Screens,
|
||||||
|
exercises and answer checking live in `android/shared/`; the platforms only
|
||||||
|
differ in their entry point (`android/app`, `android/desktop`).
|
||||||
|
- **Swift + SwiftUI** (`ios/`, `macos/`): iPhone and Mac (Xcode 26, macOS 26+).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/pepaskopec-blip/Graduately.git
|
||||||
|
cd Graduately
|
||||||
|
make test # answer checks in Kotlin (and Swift when Xcode is there)
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux, Windows (and a Mac for development) need JDK 17+ and Python 3:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run # run the desktop app from source
|
||||||
|
make linux # dist/graduately-linux-x86_64.AppImage
|
||||||
|
make windows # dist/graduately-windows-x64.zip (in Git Bash)
|
||||||
|
make smoke # installers, content, tests, builds availability
|
||||||
|
```
|
||||||
|
|
||||||
|
Packaging (`make linux` / `make windows`) needs a JDK that ships `jmods`,
|
||||||
|
such as Temurin 21; pass its path in `PACKAGE_JDK`. ProGuard keeps the
|
||||||
|
package under GitHub's 100 MB limit.
|
||||||
|
|
||||||
|
On a Mac:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open Graduately.xcworkspace # scheme Graduately from macos/Graduately.xcodeproj
|
||||||
|
# or: make macos # zip at dist/graduately-macos-arm64.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
On macOS, `make macos` embeds the current Git commit so the installed app
|
||||||
|
can check for updates. When packaging a source export without `.git`, use
|
||||||
|
`COMMIT=<source-commit> make macos`.
|
||||||
|
|
||||||
|
Skipping Gatekeeper on a downloaded macOS build needs an
|
||||||
|
**Apple Developer Program** membership and these GitHub Secrets:
|
||||||
|
|
||||||
|
- `MACOS_CERTIFICATE` — Developer ID Application `.p12`, base64-encoded
|
||||||
|
- `MACOS_CERTIFICATE_PASSWORD`
|
||||||
|
- `APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER` for notarization
|
||||||
|
(or `APPLE_ID` + `APPLE_APP_SPECIFIC_PASSWORD` + `APPLE_TEAM_ID`)
|
||||||
|
|
||||||
|
Without them the bundle stays ad-hoc signed and Gatekeeper still blocks it.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
content/ lesson, exercise and book texts in Markdown (see content/README.md)
|
||||||
|
android/shared/ shared Kotlin: screens, exercises, answer checking, progress
|
||||||
|
android/app/ Android (entry point, APK updates, speech)
|
||||||
|
android/desktop/ Linux and Windows (window, updates, speech, GTK progress import)
|
||||||
|
ios/ SwiftUI for iPhone
|
||||||
|
macos/ SwiftUI for Mac (shares code with ios/)
|
||||||
|
tests/ shared answer-check cases (answers.json) + Swift harness
|
||||||
|
data/share/ icons and the .desktop file
|
||||||
|
assets/ source icon and screenshots
|
||||||
|
installers/ fetch-the-latest installers
|
||||||
|
scripts/ build-content.py, AppImage / zip / .app / IPA bundlers, smoke test
|
||||||
|
.github/workflows/ CI: push to main → builds branch
|
||||||
|
```
|
||||||
|
|
||||||
|
Android from source (JDK 17 + Android SDK):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew :app:assembleRelease # content/ is built automatically
|
||||||
|
# APK: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
or `make android` from the repo root.
|
||||||
|
|
||||||
|
The APK is signed with `android/keystore/sideload.jks` (not a secret – this is
|
||||||
|
a sideloaded app, not a Play release) so in-app updates install over the
|
||||||
|
previous build. Supply your own key through `MATURITA_KEYSTORE` and
|
||||||
|
`MATURITA_KEYSTORE_PASSWORD`. Builds `android-5` and older carry a different
|
||||||
|
signature – uninstall once and install again.
|
||||||
|
|
||||||
|
iOS from source (Xcode 26, iOS 26+ / Liquid Glass):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open Graduately.xcworkspace # at the repo root
|
||||||
|
# Simulator: pick an iPhone and Run, nothing else needed (content is
|
||||||
|
# built from content/ during the build). Device: pick your Team.
|
||||||
|
# or: make ios # unsigned IPA at dist-ios/graduately-ios.ipa
|
||||||
|
```
|
||||||
|
|
||||||
|
The workspace points at `ios/Graduately.xcodeproj` and `macos/Graduately.xcodeproj`.
|
||||||
|
Do not move the projects – the build reads `../content` and `../scripts`.
|
||||||
|
|
||||||
|
License: [GPL-3.0](LICENSE).
|
||||||
1
VERSION
|
|
@ -1 +0,0 @@
|
||||||
25567891c0ec9f5a74a2fdd7a71ca607155c1826
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
25567891c0ec9f5a74a2fdd7a71ca607155c1826
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
25567891c0ec9f5a74a2fdd7a71ca607155c1826
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
25567891c0ec9f5a74a2fdd7a71ca607155c1826
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
25567891c0ec9f5a74a2fdd7a71ca607155c1826
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
25567891c0ec9f5a74a2fdd7a71ca607155c1826
|
|
||||||
106
android/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
val commit: String = System.getenv("COMMIT")?.takeIf { it.isNotBlank() } ?: "dev"
|
||||||
|
|
||||||
|
// Every CI build must be installable over the previous one, so the version
|
||||||
|
// code grows with the workflow run and local builds stay below it.
|
||||||
|
val ciRun: Int? = System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull()
|
||||||
|
val appVersionCode: Int = ciRun?.let { 100 + it } ?: 7
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "org.maturita.maturita"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "org.maturita.maturita"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = appVersionCode
|
||||||
|
versionName = "android-$appVersionCode"
|
||||||
|
buildConfigField("String", "COMMIT", "\"$commit\"")
|
||||||
|
buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/Graduately\"")
|
||||||
|
buildConfigField("String", "UPDATE_BRANCH", "\"builds\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android only installs an update signed with the same key as the app
|
||||||
|
// already on the phone. The default debug keystore is generated per
|
||||||
|
// machine, so CI builds would never update each other; sign everything
|
||||||
|
// with the repository's sideload key instead. It is not a secret (this
|
||||||
|
// is a sideloaded open-source app), but a real key can be supplied
|
||||||
|
// through MATURITA_KEYSTORE / MATURITA_KEYSTORE_PASSWORD.
|
||||||
|
signingConfigs {
|
||||||
|
create("sideload") {
|
||||||
|
val ks = System.getenv("MATURITA_KEYSTORE")?.takeIf { it.isNotBlank() }
|
||||||
|
?: rootProject.file("keystore/sideload.jks").path
|
||||||
|
val pass = System.getenv("MATURITA_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
|
||||||
|
?: "maturita-sideload"
|
||||||
|
storeFile = file(ks)
|
||||||
|
storePassword = pass
|
||||||
|
keyAlias = System.getenv("MATURITA_KEY_ALIAS")?.takeIf { it.isNotBlank() } ?: "maturita"
|
||||||
|
keyPassword = System.getenv("MATURITA_KEY_PASSWORD")?.takeIf { it.isNotBlank() } ?: pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig = signingConfigs.getByName("sideload")
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
signingConfig = signingConfigs.getByName("sideload")
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI, data and navigation shared with the desktop app (../desktop).
|
||||||
|
sourceSets.getByName("main").kotlin.directories.add("../shared/src")
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||||
|
implementation(composeBom)
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
implementation("androidx.core:core-ktx:1.15.0")
|
||||||
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
}
|
||||||
|
|
||||||
|
val buildContent = tasks.register<Exec>("buildContent") {
|
||||||
|
workingDir = rootProject.projectDir.parentFile
|
||||||
|
environment("PYTHONUTF8", "1")
|
||||||
|
environment("PYTHONIOENCODING", "utf-8")
|
||||||
|
commandLine("python3", "scripts/build-content.py")
|
||||||
|
inputs.dir(rootProject.projectDir.parentFile.resolve("content"))
|
||||||
|
inputs.file(rootProject.projectDir.parentFile.resolve("scripts/build-content.py"))
|
||||||
|
outputs.file(file("src/main/assets/content.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("preBuild").configure {
|
||||||
|
dependsOn(buildContent)
|
||||||
|
}
|
||||||
1
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Debug-signed student builds keep the full content JSON.
|
||||||
36
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?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.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Maturita"
|
||||||
|
android:usesCleartextTraffic="false">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden|uiMode">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.files"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package org.maturita.maturita
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import org.maturita.maturita.platform.Platform
|
||||||
|
import org.maturita.maturita.platform.Prefs
|
||||||
|
import org.maturita.maturita.platform.Speaker
|
||||||
|
import org.maturita.maturita.update.Updater
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class AndroidPlatform(private val app: Application) : Platform {
|
||||||
|
override val commit: String = BuildConfig.COMMIT
|
||||||
|
override val versionName: String = BuildConfig.VERSION_NAME
|
||||||
|
override val welcomeKey = "welcome_body_android"
|
||||||
|
override val prefs: Prefs = AndroidPrefs(app.getSharedPreferences("maturita", Context.MODE_PRIVATE))
|
||||||
|
override val updater = Updater(app)
|
||||||
|
|
||||||
|
override fun speaker(): Speaker = AndroidSpeaker(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AndroidPrefs(private val sp: SharedPreferences) : Prefs {
|
||||||
|
override fun getBoolean(key: String, fallback: Boolean) = sp.getBoolean(key, fallback)
|
||||||
|
override fun getInt(key: String, fallback: Int) = sp.getInt(key, fallback)
|
||||||
|
override fun getString(key: String, fallback: String?): String? = sp.getString(key, fallback)
|
||||||
|
override fun edit(): Prefs.Editor = Editor(sp.edit())
|
||||||
|
|
||||||
|
private class Editor(private val e: SharedPreferences.Editor) : Prefs.Editor {
|
||||||
|
override fun putBoolean(key: String, value: Boolean) = apply { e.putBoolean(key, value) }
|
||||||
|
override fun putInt(key: String, value: Int) = apply { e.putInt(key, value) }
|
||||||
|
override fun putString(key: String, value: String) = apply { e.putString(key, value) }
|
||||||
|
override fun apply() = e.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AndroidSpeaker(context: Context) : Speaker {
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private var ready = false
|
||||||
|
private val tts: TextToSpeech = TextToSpeech(context) { status ->
|
||||||
|
ready = status == TextToSpeech.SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun speak(
|
||||||
|
text: String,
|
||||||
|
locale: Locale,
|
||||||
|
rate: Float,
|
||||||
|
volume: Float,
|
||||||
|
onRange: (Int) -> Unit,
|
||||||
|
onEnd: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
if (!ready) {
|
||||||
|
main.post { onEnd(false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val id = "en-${System.nanoTime()}"
|
||||||
|
tts.language = locale
|
||||||
|
tts.setSpeechRate(rate)
|
||||||
|
tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(utteranceId: String?) {}
|
||||||
|
override fun onRangeStart(utteranceId: String?, rangeStart: Int, rangeEnd: Int, frame: Int) {
|
||||||
|
if (utteranceId == id) main.post { onRange(rangeStart) }
|
||||||
|
}
|
||||||
|
override fun onDone(utteranceId: String?) {
|
||||||
|
if (utteranceId == id) main.post { onEnd(true) }
|
||||||
|
}
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onError(utteranceId: String?) {
|
||||||
|
if (utteranceId == id) main.post { onEnd(false) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
val params = Bundle().apply { putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume) }
|
||||||
|
tts.speak(text, TextToSpeech.QUEUE_FLUSH, params, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
tts.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
tts.stop()
|
||||||
|
tts.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
package org.maturita.maturita
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import org.maturita.maturita.data.Content
|
||||||
|
|
||||||
|
/** Keeps the shared app state alive across rotation and other config changes. */
|
||||||
|
class AppHolder(app: Application) : AndroidViewModel(app) {
|
||||||
|
val vm = AppViewModel(
|
||||||
|
Content.parse(runCatching { app.assets.open("content.json").bufferedReader().use { it.readText() } }.getOrNull()),
|
||||||
|
AndroidPlatform(app),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val holder: AppHolder by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val crashPrefs = getSharedPreferences("crash", MODE_PRIVATE)
|
||||||
|
val prev = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
|
||||||
|
try {
|
||||||
|
crashPrefs.edit().putString("last", error.stackTraceToString()).commit()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
prev?.uncaughtException(thread, error)
|
||||||
|
}
|
||||||
|
val lastCrash = crashPrefs.getString("last", null)
|
||||||
|
if (lastCrash != null) {
|
||||||
|
setContentView(crashView(lastCrash) {
|
||||||
|
crashPrefs.edit().remove("last").apply()
|
||||||
|
recreate()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableEdgeToEdge()
|
||||||
|
val vm = holder.vm
|
||||||
|
setContent {
|
||||||
|
BackHandler(vm.canGoBack || vm.searchOpen) { vm.handleBack() }
|
||||||
|
MaturitaApp(vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun crashView(dump: String, retry: () -> Unit): LinearLayout {
|
||||||
|
val pad = (20 * resources.displayMetrics.density).toInt()
|
||||||
|
val title = TextView(this).apply {
|
||||||
|
text = "Graduately spadla"
|
||||||
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
|
||||||
|
setPadding(0, 0, 0, pad / 2)
|
||||||
|
}
|
||||||
|
val hint = TextView(this).apply {
|
||||||
|
text = "Pošli tenhle text, ať jde pád opravit."
|
||||||
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
|
||||||
|
setPadding(0, 0, 0, pad / 2)
|
||||||
|
}
|
||||||
|
val body = TextView(this).apply {
|
||||||
|
text = dump
|
||||||
|
setTextIsSelectable(true)
|
||||||
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
|
||||||
|
typeface = android.graphics.Typeface.MONOSPACE
|
||||||
|
}
|
||||||
|
val scroll = ScrollView(this).apply {
|
||||||
|
addView(body, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||||
|
}
|
||||||
|
val retryBtn = Button(this).apply {
|
||||||
|
text = "Zkusit znovu"
|
||||||
|
setOnClickListener { retry() }
|
||||||
|
}
|
||||||
|
return LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
addView(title, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||||
|
addView(hint, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||||
|
addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
||||||
|
addView(retryBtn, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
package org.maturita.maturita.update
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import org.maturita.maturita.BuildConfig
|
||||||
|
import org.maturita.maturita.platform.AppUpdater
|
||||||
|
import org.maturita.maturita.platform.UpdateState
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-update from the `builds` branch. CI rewrites that branch on every
|
||||||
|
* push to main with a commit titled "Build from <sha>"; the APK there is
|
||||||
|
* signed with the repository's sideload key, so Android accepts it as an
|
||||||
|
* update over any earlier build signed the same way.
|
||||||
|
*/
|
||||||
|
class Updater(private val app: Application) : AppUpdater {
|
||||||
|
private val io = Executors.newSingleThreadExecutor()
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private val repo = BuildConfig.UPDATE_REPO
|
||||||
|
private val branch = BuildConfig.UPDATE_BRANCH
|
||||||
|
|
||||||
|
private fun emit(cb: (UpdateState) -> Unit, state: UpdateState) {
|
||||||
|
main.post { cb(state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun check(interactive: Boolean, cb: (UpdateState) -> Unit) {
|
||||||
|
if (BuildConfig.COMMIT == "dev") {
|
||||||
|
emit(cb, UpdateState(status = "dev", messageKey = "update_err_devbuild", messageArg = null))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (interactive) {
|
||||||
|
emit(cb, UpdateState(status = "checking", messageKey = "update_checking", messageArg = null))
|
||||||
|
}
|
||||||
|
io.execute {
|
||||||
|
try {
|
||||||
|
val atom = get("https://github.com/$repo/commits/$branch.atom")
|
||||||
|
val tip = atomTip(atom) ?: error("sha")
|
||||||
|
val remote = remoteAndroidCommit(atom, tip)
|
||||||
|
val local = BuildConfig.COMMIT
|
||||||
|
if (sameCommit(remote, local)) {
|
||||||
|
emit(cb, UpdateState("ok", remote, "update_uptodate", remote.take(7)))
|
||||||
|
} else {
|
||||||
|
emit(cb, UpdateState("available", remote, "update_available", remote.take(7), true))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emit(cb, UpdateState("error", messageKey = "update_err_network", messageArg = null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun install(cb: (UpdateState) -> Unit) {
|
||||||
|
// Android 8+ needs a per-app opt-in before we may hand an APK to the
|
||||||
|
// package installer. Send the user there and keep the Update button
|
||||||
|
// so the second tap goes straight to the download.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||||
|
!app.packageManager.canRequestPackageInstalls()
|
||||||
|
) {
|
||||||
|
val settings = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
|
data = Uri.parse("package:${app.packageName}")
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
app.startActivity(settings)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
emit(cb, UpdateState("permission", messageKey = "update_android_allow", messageArg = null, canInstall = true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit(cb, UpdateState(status = "downloading", messageKey = "update_downloading", messageArg = null))
|
||||||
|
io.execute {
|
||||||
|
var tip: String? = null
|
||||||
|
try {
|
||||||
|
val atom = get("https://github.com/$repo/commits/$branch.atom")
|
||||||
|
val head = atomTip(atom) ?: error("sha")
|
||||||
|
tip = head
|
||||||
|
val remote = remoteAndroidCommit(atom, head)
|
||||||
|
// Pin the download to the commit: the branch-named URL can be
|
||||||
|
// served from a stale CDN cache (even a cached 404).
|
||||||
|
val url = "https://raw.githubusercontent.com/$repo/$head/graduately-android.apk"
|
||||||
|
val dir = File(app.cacheDir, "updates").apply { mkdirs() }
|
||||||
|
val apk = File(dir, "graduately.apk")
|
||||||
|
download(url, apk)
|
||||||
|
if (!isOurApk(apk)) {
|
||||||
|
apk.delete()
|
||||||
|
emit(cb, UpdateState("error", remote, "update_err_badapk", null, canInstall = true))
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", apk)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
}
|
||||||
|
main.post { app.startActivity(intent) }
|
||||||
|
emit(cb, UpdateState("staged", remote, "update_android_installing", remote.take(7), canInstall = true))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emit(cb, UpdateState("error", tip, "update_err_download", null, canInstall = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit whose APK sits in the builds branch. `VERSION-android` is written
|
||||||
|
* only when the Android job produced a package; older branches lack it,
|
||||||
|
* so fall back to the commit the whole branch was built from.
|
||||||
|
*/
|
||||||
|
private fun remoteAndroidCommit(atom: String, tip: String): String {
|
||||||
|
val marker = runCatching {
|
||||||
|
get("https://raw.githubusercontent.com/$repo/$tip/VERSION-android").trim()
|
||||||
|
}.getOrNull()
|
||||||
|
if (marker != null && marker.length >= 7) return marker
|
||||||
|
return atomMainSha(atom)
|
||||||
|
?: get("https://raw.githubusercontent.com/$repo/$tip/VERSION").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun atomTip(atom: String): String? =
|
||||||
|
Regex("""Commit/([0-9a-f]{40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1)
|
||||||
|
?: Regex("""/commit/([0-9a-f]{7,40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1)
|
||||||
|
|
||||||
|
private fun atomMainSha(atom: String): String? =
|
||||||
|
Regex("""Build from ([0-9a-f]{7,40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1)
|
||||||
|
|
||||||
|
private fun sameCommit(a: String, b: String): Boolean {
|
||||||
|
val n = minOf(a.length, b.length)
|
||||||
|
return n >= 7 && a.take(n).equals(b.take(n), ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject anything that is not a parseable APK of this very app. */
|
||||||
|
private fun isOurApk(file: File): Boolean {
|
||||||
|
if (file.length() < 1_000_000L) return false
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
val header = ByteArray(2)
|
||||||
|
if (input.read(header) != 2) return false
|
||||||
|
if (header[0] != 'P'.code.toByte() || header[1] != 'K'.code.toByte()) return false
|
||||||
|
}
|
||||||
|
val info = app.packageManager.getPackageArchiveInfo(file.absolutePath, 0) ?: return false
|
||||||
|
return info.packageName == app.packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(url: String, readMs: Int): HttpURLConnection {
|
||||||
|
return (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15000
|
||||||
|
readTimeout = readMs
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
setRequestProperty("User-Agent", "Graduately-android")
|
||||||
|
setRequestProperty("Accept", "*/*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun get(url: String): String {
|
||||||
|
val c = open(url, 15000)
|
||||||
|
return try {
|
||||||
|
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
|
||||||
|
c.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(url: String, dest: File) {
|
||||||
|
val c = open(url, 60000)
|
||||||
|
try {
|
||||||
|
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
|
||||||
|
c.inputStream.use { input -> dest.outputStream().use { input.copyTo(it) } }
|
||||||
|
} finally {
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_bg" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_fg" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_bg" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_fg" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_bg">#E59F64</color>
|
||||||
|
</resources>
|
||||||
4
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Graduately</string>
|
||||||
|
</resources>
|
||||||
8
android/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Maturita" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowBackground">#FAF7F2</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
4
android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="updates" path="updates/" />
|
||||||
|
</paths>
|
||||||
6
android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.1.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false
|
||||||
|
id("org.jetbrains.kotlin.jvm") version "2.2.10" apply false
|
||||||
|
id("org.jetbrains.compose") version "1.9.3" apply false
|
||||||
|
}
|
||||||
105
android/desktop/build.gradle.kts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// Linux and Windows app: the same Compose UI as Android (../shared), packaged
|
||||||
|
// with its own Java runtime by jpackage.
|
||||||
|
plugins {
|
||||||
|
id("org.jetbrains.kotlin.jvm")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
id("org.jetbrains.compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
val commit: String = System.getenv("COMMIT")?.takeIf { it.isNotBlank() } ?: "dev"
|
||||||
|
val repoRoot: File = rootProject.projectDir.parentFile
|
||||||
|
val generated = layout.buildDirectory.dir("generated/resources")
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
sourceSets.named("main") {
|
||||||
|
kotlin.srcDir("../shared/src")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets.named("main") {
|
||||||
|
resources.srcDir(generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3")
|
||||||
|
implementation("org.json:json:20250517")
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val buildContent = tasks.register<Exec>("buildContent") {
|
||||||
|
val out = generated.get().file("content.json").asFile
|
||||||
|
workingDir = repoRoot
|
||||||
|
val python = System.getenv("PYTHON")?.takeIf { it.isNotBlank() }
|
||||||
|
?: if (System.getProperty("os.name").startsWith("Windows")) "python" else "python3"
|
||||||
|
environment("PYTHONUTF8", "1")
|
||||||
|
environment("PYTHONIOENCODING", "utf-8")
|
||||||
|
commandLine(python, "scripts/build-content.py", "--out", out.path)
|
||||||
|
inputs.dir(repoRoot.resolve("content"))
|
||||||
|
inputs.file(repoRoot.resolve("scripts/build-content.py"))
|
||||||
|
outputs.file(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
val buildInfo = tasks.register("buildInfo") {
|
||||||
|
val out = generated.get().file("build-info.properties").asFile
|
||||||
|
inputs.property("commit", commit)
|
||||||
|
outputs.file(out)
|
||||||
|
doLast {
|
||||||
|
out.parentFile.mkdirs()
|
||||||
|
out.writeText("commit=$commit\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val appIcon = tasks.register<Copy>("appIcon") {
|
||||||
|
from(repoRoot.resolve("data/share/icons/hicolor/256x256/apps/maturita.png"))
|
||||||
|
into(generated)
|
||||||
|
rename { "icon.png" }
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("processResources") {
|
||||||
|
dependsOn(buildContent, buildInfo, appIcon)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
systemProperty("graduately.repo", repoRoot.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
compose.desktop {
|
||||||
|
application {
|
||||||
|
mainClass = "org.maturita.maturita.desktop.MainKt"
|
||||||
|
// jpackage and ProGuard need a JDK with jmods (Temurin 21 has them, 24+ may not).
|
||||||
|
System.getenv("PACKAGE_JDK")?.takeIf { it.isNotBlank() }?.let { javaHome = it }
|
||||||
|
// Shrinking drops the thousands of unused icons and keeps the
|
||||||
|
// packages under GitHub's 100 MB file limit.
|
||||||
|
buildTypes.release.proguard {
|
||||||
|
version.set("7.10.0")
|
||||||
|
obfuscate.set(false)
|
||||||
|
configurationFiles.from(project.file("proguard-rules.pro"))
|
||||||
|
}
|
||||||
|
nativeDistributions {
|
||||||
|
// The launcher must stay graduately(.exe): installers and the
|
||||||
|
// updater of older builds start it by that name.
|
||||||
|
packageName = "graduately"
|
||||||
|
packageVersion = "1.0.0"
|
||||||
|
description = "Graduately"
|
||||||
|
vendor = "Graduately"
|
||||||
|
modules("java.naming", "jdk.crypto.ec", "jdk.unsupported")
|
||||||
|
linux {
|
||||||
|
iconFile.set(repoRoot.resolve("data/share/icons/hicolor/512x512/apps/maturita.png"))
|
||||||
|
}
|
||||||
|
windows {
|
||||||
|
iconFile.set(repoRoot.resolve("assets/app-icon.ico"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
android/desktop/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-dontwarn org.slf4j.**
|
||||||
|
-dontnote **
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package org.maturita.maturita.desktop
|
||||||
|
|
||||||
|
import org.maturita.maturita.platform.CSharpRun
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** One cached console project. The student's program replaces Program.cs and `dotnet run` executes it. */
|
||||||
|
object CSharpLab {
|
||||||
|
private val gate = Object()
|
||||||
|
|
||||||
|
fun run(source: String, stdin: String): CSharpRun {
|
||||||
|
if (source.length > 100_000) return CSharpRun("fail", "Program je příliš dlouhý.")
|
||||||
|
val dotnet = findDotnet() ?: return CSharpRun("unavailable", "")
|
||||||
|
synchronized(gate) {
|
||||||
|
return try {
|
||||||
|
execute(dotnet, source, stdin)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
CSharpRun("fail", e.message ?: "Spuštění selhalo.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun execute(dotnet: String, source: String, stdin: String): CSharpRun {
|
||||||
|
val dir = File(System.getProperty("user.home"), ".graduately/csharp-lab")
|
||||||
|
dir.mkdirs()
|
||||||
|
val csproj = File(dir, "Lab.csproj")
|
||||||
|
if (!csproj.isFile) {
|
||||||
|
csproj.writeText(projectFile(targetFramework(dotnet)), StandardCharsets.UTF_8)
|
||||||
|
val restored = command(dotnet, listOf("restore", dir.absolutePath), dir, "", 90)
|
||||||
|
if (restored.status != "ok") return restored
|
||||||
|
}
|
||||||
|
File(dir, "Program.cs").writeText(source, StandardCharsets.UTF_8)
|
||||||
|
val args = mutableListOf("run", "--project", dir.absolutePath, "--verbosity", "quiet")
|
||||||
|
if (File(dir, "obj").isDirectory) args += "--no-restore"
|
||||||
|
return command(dotnet, args, dir, stdin, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun command(dotnet: String, args: List<String>, dir: File, stdin: String, seconds: Long): CSharpRun {
|
||||||
|
val process = ProcessBuilder(listOf(dotnet) + args)
|
||||||
|
.directory(dir)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.apply {
|
||||||
|
environment().clear()
|
||||||
|
environment().putAll(environmentFor(dotnet))
|
||||||
|
}
|
||||||
|
.start()
|
||||||
|
process.outputStream.use { it.write(stdin.toByteArray(StandardCharsets.UTF_8)) }
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val reader = Thread {
|
||||||
|
process.inputStream.use { it.copyTo(output) }
|
||||||
|
}
|
||||||
|
reader.isDaemon = true
|
||||||
|
reader.start()
|
||||||
|
val finished = process.waitFor(seconds, TimeUnit.SECONDS)
|
||||||
|
if (!finished) {
|
||||||
|
process.descendants().forEach { it.destroyForcibly() }
|
||||||
|
process.destroyForcibly()
|
||||||
|
process.waitFor(2, TimeUnit.SECONDS)
|
||||||
|
reader.join(1000)
|
||||||
|
return CSharpRun("timeout", clip(output.toString(StandardCharsets.UTF_8)))
|
||||||
|
}
|
||||||
|
reader.join(2000)
|
||||||
|
val text = clip(output.toString(StandardCharsets.UTF_8)).trim()
|
||||||
|
return if (process.exitValue() == 0) CSharpRun("ok", text) else CSharpRun("fail", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clip(text: String): String =
|
||||||
|
if (text.length <= 16_000) text else text.take(16_000) + "\n…"
|
||||||
|
|
||||||
|
private fun environmentFor(dotnet: String): Map<String, String> {
|
||||||
|
val home = System.getProperty("user.home")
|
||||||
|
val root = File(dotnet).parent
|
||||||
|
val path = listOf(root, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin")
|
||||||
|
.joinToString(File.pathSeparator)
|
||||||
|
val env = mutableMapOf(
|
||||||
|
"PATH" to path,
|
||||||
|
"HOME" to home,
|
||||||
|
"USERPROFILE" to home,
|
||||||
|
"DOTNET_ROOT" to root,
|
||||||
|
"DOTNET_CLI_TELEMETRY_OPTOUT" to "1",
|
||||||
|
"DOTNET_NOLOGO" to "1",
|
||||||
|
"DOTNET_SKIP_FIRST_TIME_EXPERIENCE" to "1",
|
||||||
|
"TEMP" to System.getProperty("java.io.tmpdir"),
|
||||||
|
"TMP" to System.getProperty("java.io.tmpdir"),
|
||||||
|
)
|
||||||
|
System.getenv("SystemRoot")?.let { env["SystemRoot"] = it }
|
||||||
|
System.getenv("ComSpec")?.let { env["ComSpec"] = it }
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun targetFramework(dotnet: String): String {
|
||||||
|
val version = command(dotnet, listOf("--version"), File(System.getProperty("java.io.tmpdir")), "", 20)
|
||||||
|
val major = version.output.trim().substringBefore('.').filter { it.isDigit() }
|
||||||
|
return if (major.isEmpty()) "net8.0" else "net$major.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun projectFile(framework: String) = """
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>$framework</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private fun findDotnet(): String? {
|
||||||
|
val home = System.getProperty("user.home")
|
||||||
|
val programFiles = System.getenv("ProgramFiles") ?: "C:\\Program Files"
|
||||||
|
val names = listOf(
|
||||||
|
"/usr/local/share/dotnet/dotnet",
|
||||||
|
"/usr/share/dotnet/dotnet",
|
||||||
|
"/opt/homebrew/share/dotnet/dotnet",
|
||||||
|
"$home/.dotnet/dotnet",
|
||||||
|
"$programFiles\\dotnet\\dotnet.exe",
|
||||||
|
)
|
||||||
|
names.firstOrNull { File(it).canExecute() }?.let { return it }
|
||||||
|
val path = System.getenv("PATH") ?: return null
|
||||||
|
return path.split(File.pathSeparator)
|
||||||
|
.map { File(it, if (System.getProperty("os.name").startsWith("Windows")) "dotnet.exe" else "dotnet") }
|
||||||
|
.firstOrNull { it.canExecute() }
|
||||||
|
?.absolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package org.maturita.maturita.desktop
|
||||||
|
|
||||||
|
import org.maturita.maturita.platform.CSharpRun
|
||||||
|
import org.maturita.maturita.platform.Platform
|
||||||
|
import org.maturita.maturita.platform.Prefs
|
||||||
|
import org.maturita.maturita.platform.Speaker
|
||||||
|
import javax.swing.SwingUtilities
|
||||||
|
|
||||||
|
class DesktopPlatform(
|
||||||
|
override val commit: String,
|
||||||
|
override val prefs: Prefs,
|
||||||
|
install: Install,
|
||||||
|
) : Platform {
|
||||||
|
override val versionName: String = if (Install.isWindows) "windows" else "linux"
|
||||||
|
override val welcomeKey = "welcome_body_desktop"
|
||||||
|
override val updater = DesktopUpdater(commit, install)
|
||||||
|
|
||||||
|
override fun speaker(): Speaker? = DesktopSpeaker.create()
|
||||||
|
|
||||||
|
override fun runCSharp(source: String, stdin: String, cb: (CSharpRun) -> Unit) {
|
||||||
|
Thread {
|
||||||
|
val result = CSharpLab.run(source, stdin)
|
||||||
|
SwingUtilities.invokeLater { cb(result) }
|
||||||
|
}.apply { isDaemon = true }.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package org.maturita.maturita.desktop
|
||||||
|
|
||||||
|
import org.maturita.maturita.platform.Speaker
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.swing.SwingUtilities
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speech through whatever the system already has: SAPI via PowerShell on
|
||||||
|
* Windows, espeak-ng / espeak / spd-say on Linux, `say` on a Mac. None of them
|
||||||
|
* report word positions, so pause and resume start the passage again.
|
||||||
|
*/
|
||||||
|
class DesktopSpeaker private constructor(private val command: (String, Locale, Float, Float) -> List<String>) : Speaker {
|
||||||
|
@Volatile private var process: Process? = null
|
||||||
|
|
||||||
|
override fun speak(
|
||||||
|
text: String,
|
||||||
|
locale: Locale,
|
||||||
|
rate: Float,
|
||||||
|
volume: Float,
|
||||||
|
onRange: (Int) -> Unit,
|
||||||
|
onEnd: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
stop()
|
||||||
|
val argv = command(text, locale, rate, volume)
|
||||||
|
val stdin = argv.lastOrNull() == STDIN
|
||||||
|
val p = runCatching {
|
||||||
|
ProcessBuilder(if (stdin) argv.dropLast(1) else argv)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||||
|
.start()
|
||||||
|
}.getOrElse {
|
||||||
|
SwingUtilities.invokeLater { onEnd(false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process = p
|
||||||
|
thread(isDaemon = true, name = "speech") {
|
||||||
|
if (stdin) runCatching { p.outputStream.writer(Charsets.UTF_8).use { it.write(text) } }
|
||||||
|
val ok = p.waitFor() == 0
|
||||||
|
if (process === p) {
|
||||||
|
process = null
|
||||||
|
SwingUtilities.invokeLater { onEnd(ok) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
process?.let {
|
||||||
|
process = null
|
||||||
|
it.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() = stop()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Marks a command that reads the text from standard input. */
|
||||||
|
private const val STDIN = "\u0000stdin"
|
||||||
|
|
||||||
|
private fun onPath(name: String): Boolean =
|
||||||
|
System.getenv("PATH").orEmpty().split(File.pathSeparator).any { File(it, name).canExecute() }
|
||||||
|
|
||||||
|
fun create(): DesktopSpeaker? {
|
||||||
|
val os = System.getProperty("os.name").orEmpty()
|
||||||
|
return when {
|
||||||
|
os.startsWith("Windows") -> DesktopSpeaker { _, locale, rate, volume ->
|
||||||
|
val sapiRate = ((rate - 1f) * 10f).toInt().coerceIn(-10, 10)
|
||||||
|
val script = "[Console]::InputEncoding=[Text.Encoding]::UTF8;" +
|
||||||
|
"Add-Type -AssemblyName System.Speech;" +
|
||||||
|
"\$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" +
|
||||||
|
"try{\$s.SelectVoiceByHints('NotSet','NotSet',0,[Globalization.CultureInfo]'${locale.toLanguageTag()}')}catch{};" +
|
||||||
|
"\$s.Rate=$sapiRate;\$s.Volume=${(volume * 100).toInt()};" +
|
||||||
|
"\$s.Speak([Console]::In.ReadToEnd())"
|
||||||
|
listOf("powershell", "-NoProfile", "-NonInteractive", "-Command", script, STDIN)
|
||||||
|
}
|
||||||
|
os.startsWith("Mac") -> DesktopSpeaker { text, _, rate, _ ->
|
||||||
|
listOf("say", "-r", (180 * rate).toInt().toString(), text)
|
||||||
|
}
|
||||||
|
onPath("espeak-ng") || onPath("espeak") -> {
|
||||||
|
val bin = if (onPath("espeak-ng")) "espeak-ng" else "espeak"
|
||||||
|
DesktopSpeaker { _, locale, rate, volume ->
|
||||||
|
val voice = if (locale.language == "de") "de" else "en-gb"
|
||||||
|
listOf(bin, "-v", voice, "-s", (165 * rate).toInt().toString(), "-a", (volume * 150).toInt().toString(), "--stdin", STDIN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onPath("spd-say") -> DesktopSpeaker { text, locale, rate, volume ->
|
||||||
|
listOf(
|
||||||
|
"spd-say", "-w", "-l", locale.language,
|
||||||
|
"-r", ((rate - 1f) * 100f).toInt().coerceIn(-100, 100).toString(),
|
||||||
|
"-i", ((volume - 1f) * 100f).toInt().coerceIn(-100, 100).toString(),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
package org.maturita.maturita.desktop
|
||||||
|
|
||||||
|
import org.maturita.maturita.platform.AppUpdater
|
||||||
|
import org.maturita.maturita.platform.UpdateState
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URI
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import javax.swing.SwingUtilities
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-update from the `builds` branch, the same scheme the GTK builds used,
|
||||||
|
* so their updater and this one hand over cleanly: the asset names stay, and
|
||||||
|
* progress/ next to the app survives the swap.
|
||||||
|
*
|
||||||
|
* raw.githubusercontent.com caches branch-named URLs, so the branch tip is
|
||||||
|
* read from the Atom feed and every file is fetched from that commit. A running
|
||||||
|
* app cannot replace itself; a small script waits for this process to exit,
|
||||||
|
* swaps the new version in and starts it.
|
||||||
|
*/
|
||||||
|
class DesktopUpdater(private val commit: String, private val install: Install) : AppUpdater {
|
||||||
|
private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "updater").apply { isDaemon = true } }
|
||||||
|
private val repo = "pepaskopec-blip/Graduately"
|
||||||
|
private val branch = "builds"
|
||||||
|
private val platform = if (Install.isWindows) "windows" else "linux"
|
||||||
|
private val asset = if (Install.isWindows) "graduately-windows-x64.zip" else "graduately-linux-x86_64.AppImage"
|
||||||
|
|
||||||
|
private fun emit(cb: (UpdateState) -> Unit, state: UpdateState) = SwingUtilities.invokeLater { cb(state) }
|
||||||
|
|
||||||
|
override fun check(interactive: Boolean, cb: (UpdateState) -> Unit) {
|
||||||
|
if (!commit.matches(Regex("[0-9a-fA-F]{7,40}"))) {
|
||||||
|
emit(cb, UpdateState("dev", messageKey = "update_err_devbuild"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (interactive) emit(cb, UpdateState("checking", messageKey = "update_checking"))
|
||||||
|
io.execute {
|
||||||
|
try {
|
||||||
|
val atom = get("https://github.com/$repo/commits/$branch.atom")
|
||||||
|
val tip = atomTip(atom) ?: error("no tip")
|
||||||
|
val remote = remoteCommit(atom, tip)
|
||||||
|
emit(cb, when {
|
||||||
|
sameCommit(remote, commit) -> UpdateState("ok", remote, "update_uptodate", remote.take(7))
|
||||||
|
install.kind == Install.Kind.Unsupported ->
|
||||||
|
UpdateState("unsupported", remote, if (interactive) "update_err_unsupported" else "update_current", commit.take(7))
|
||||||
|
else -> UpdateState("available", remote, "update_available", remote.take(7), canInstall = true)
|
||||||
|
})
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emit(cb, if (interactive) UpdateState("error", messageKey = "update_err_network")
|
||||||
|
else UpdateState(messageArg = commit.take(7)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun install(cb: (UpdateState) -> Unit) {
|
||||||
|
val target = install.target
|
||||||
|
if (install.kind == Install.Kind.Unsupported || target == null) {
|
||||||
|
emit(cb, UpdateState("error", messageKey = "update_err_unsupported"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit(cb, UpdateState("downloading", messageKey = "update_downloading"))
|
||||||
|
io.execute {
|
||||||
|
val staging = File(System.getProperty("java.io.tmpdir"), "graduately-update")
|
||||||
|
val source: File
|
||||||
|
try {
|
||||||
|
val tip = atomTip(get("https://github.com/$repo/commits/$branch.atom")) ?: error("no tip")
|
||||||
|
staging.deleteRecursively()
|
||||||
|
staging.mkdirs()
|
||||||
|
val file = File(staging, asset)
|
||||||
|
download("https://raw.githubusercontent.com/$repo/$tip/$asset", file)
|
||||||
|
source = if (install.kind == Install.Kind.WindowsDir) {
|
||||||
|
File(staging, "new").also { unzip(file, it) }
|
||||||
|
.takeIf { File(it, "graduately.exe").isFile } ?: error("bad zip")
|
||||||
|
} else {
|
||||||
|
file.takeIf { isElf(it) }?.also { it.setExecutable(true) } ?: error("bad AppImage")
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
staging.deleteRecursively()
|
||||||
|
emit(cb, UpdateState("error", messageKey = "update_err_download", canInstall = true))
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startSwap(target, source, staging)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emit(cb, UpdateState("error", messageKey = "update_err_stage", canInstall = true))
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
emit(cb, UpdateState("staged", messageKey = "update_staged"))
|
||||||
|
SwingUtilities.invokeLater { exitProcess(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSwap(target: File, source: File, staging: File) {
|
||||||
|
val pid = ProcessHandle.current().pid().toString()
|
||||||
|
val windows = install.kind == Install.Kind.WindowsDir
|
||||||
|
val script = File(System.getProperty("java.io.tmpdir"), if (windows) "graduately-update.cmd" else "graduately-update.sh")
|
||||||
|
script.writeText(if (windows) WINDOWS_SWAP else UNIX_SWAP)
|
||||||
|
script.setExecutable(true)
|
||||||
|
val argv = if (windows) listOf("cmd", "/c", script.path) else listOf("/bin/sh", script.path)
|
||||||
|
val pb = ProcessBuilder(argv + listOf(pid, target.path, source.path, staging.path))
|
||||||
|
// The new AppImage must get its own mount, not inherit this one.
|
||||||
|
pb.environment().keys.removeAll(setOf("APPIMAGE", "APPDIR", "ARGV0", "OWD"))
|
||||||
|
val p = pb.redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start()
|
||||||
|
// The script detaches itself and exits; wait so we do not quit first.
|
||||||
|
p.waitFor()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit whose package for this platform sits on the branch. */
|
||||||
|
private fun remoteCommit(atom: String, tip: String): String {
|
||||||
|
val marker = runCatching { get("https://raw.githubusercontent.com/$repo/$tip/VERSION-$platform").trim() }.getOrNull()
|
||||||
|
if (marker != null && marker.length >= 7) return marker
|
||||||
|
return atomMainSha(atom) ?: get("https://raw.githubusercontent.com/$repo/$tip/VERSION").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun atomTip(atom: String): String? =
|
||||||
|
Regex("""Commit/([0-9a-f]{40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1)
|
||||||
|
|
||||||
|
private fun atomMainSha(atom: String): String? =
|
||||||
|
Regex("""Build from ([0-9a-f]{7,40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1)
|
||||||
|
|
||||||
|
private fun sameCommit(a: String, b: String): Boolean {
|
||||||
|
val n = minOf(a.length, b.length)
|
||||||
|
return n >= 7 && a.take(n).equals(b.take(n), ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isElf(f: File): Boolean = f.length() > 1_000_000L &&
|
||||||
|
f.inputStream().use { val h = ByteArray(4); it.read(h) == 4 && h.contentEquals(byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())) }
|
||||||
|
|
||||||
|
private fun unzip(zip: File, dest: File) {
|
||||||
|
val root = dest.canonicalFile
|
||||||
|
ZipInputStream(zip.inputStream().buffered()).use { z ->
|
||||||
|
while (true) {
|
||||||
|
val e = z.nextEntry ?: break
|
||||||
|
val out = File(root, e.name).canonicalFile
|
||||||
|
if (!out.path.startsWith(root.path + File.separator)) error("zip slip")
|
||||||
|
if (e.isDirectory) out.mkdirs() else {
|
||||||
|
out.parentFile.mkdirs()
|
||||||
|
out.outputStream().use { z.copyTo(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(url: String, readMs: Int): HttpURLConnection =
|
||||||
|
(URI(url).toURL().openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15000
|
||||||
|
readTimeout = readMs
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
setRequestProperty("User-Agent", "Graduately-desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun get(url: String): String {
|
||||||
|
val c = open(url, 15000)
|
||||||
|
try {
|
||||||
|
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
|
||||||
|
return c.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(url: String, dest: File) {
|
||||||
|
val c = open(url, 60000)
|
||||||
|
try {
|
||||||
|
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
|
||||||
|
c.inputStream.use { input -> dest.outputStream().use { input.copyTo(it) } }
|
||||||
|
} finally {
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Arguments: pid target source staging.
|
||||||
|
const val UNIX_SWAP = """#!/bin/sh
|
||||||
|
if [ -z "${'$'}GRADUATELY_UPDATE_DETACHED" ]; then
|
||||||
|
GRADUATELY_UPDATE_DETACHED=1
|
||||||
|
export GRADUATELY_UPDATE_DETACHED
|
||||||
|
nohup /bin/sh "${'$'}0" "${'$'}@" >/dev/null 2>&1 &
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
pid=${'$'}1; target=${'$'}2; source=${'$'}3; staging=${'$'}4
|
||||||
|
n=0
|
||||||
|
while kill -0 "${'$'}pid" 2>/dev/null && [ ${'$'}n -lt 600 ]; do
|
||||||
|
sleep 0.2; n=${'$'}((n+1))
|
||||||
|
done
|
||||||
|
if mv -f "${'$'}source" "${'$'}target" 2>/dev/null; then
|
||||||
|
chmod +x "${'$'}target"
|
||||||
|
fi
|
||||||
|
"${'$'}target" >/dev/null 2>&1 &
|
||||||
|
rm -rf "${'$'}staging"
|
||||||
|
rm -f "${'$'}0"
|
||||||
|
"""
|
||||||
|
|
||||||
|
// robocopy mirrors the new build over the old one (so nothing stale is
|
||||||
|
// left behind) but never touches progress/. ping is a sleep without a
|
||||||
|
// console window.
|
||||||
|
val WINDOWS_SWAP = """@echo off
|
||||||
|
if not defined GRADUATELY_UPDATE_DETACHED (
|
||||||
|
set GRADUATELY_UPDATE_DETACHED=1
|
||||||
|
start "" /b cmd /c "%~f0" %*
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
set "PID=%~1"
|
||||||
|
set "TARGET=%~2"
|
||||||
|
set "SOURCE=%~3"
|
||||||
|
set "STAGING=%~4"
|
||||||
|
:wait
|
||||||
|
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
|
||||||
|
if not errorlevel 1 (
|
||||||
|
ping -n 2 127.0.0.1 >nul
|
||||||
|
goto wait
|
||||||
|
)
|
||||||
|
robocopy "%SOURCE%" "%TARGET%" /MIR /XD progress /R:5 /W:1 /NFL /NDL /NJH /NJS /NP >nul
|
||||||
|
start "" "%TARGET%\graduately.exe"
|
||||||
|
rmdir /s /q "%STAGING%"
|
||||||
|
(goto) 2>nul & del "%~f0"
|
||||||
|
""".replace("\n", "\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package org.maturita.maturita.desktop
|
||||||
|
|
||||||
|
import org.maturita.maturita.platform.Prefs
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
/** Settings and progress in one properties file, rewritten atomically on every change. */
|
||||||
|
class FilePrefs(private val file: File) : Prefs {
|
||||||
|
private val values = Properties()
|
||||||
|
|
||||||
|
init {
|
||||||
|
runCatching { file.inputStream().use { values.load(it.reader(Charsets.UTF_8)) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getBoolean(key: String, fallback: Boolean) =
|
||||||
|
values.getProperty(key)?.toBooleanStrictOrNull() ?: fallback
|
||||||
|
|
||||||
|
override fun getInt(key: String, fallback: Int) =
|
||||||
|
values.getProperty(key)?.toIntOrNull() ?: fallback
|
||||||
|
|
||||||
|
override fun getString(key: String, fallback: String?): String? =
|
||||||
|
values.getProperty(key) ?: fallback
|
||||||
|
|
||||||
|
override fun edit(): Prefs.Editor = Editor()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun save(changes: Map<String, String>) {
|
||||||
|
values.putAll(changes)
|
||||||
|
runCatching {
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
val tmp = File(file.path + ".tmp")
|
||||||
|
tmp.writer(Charsets.UTF_8).use { values.store(it, null) }
|
||||||
|
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class Editor : Prefs.Editor {
|
||||||
|
private val changes = LinkedHashMap<String, String>()
|
||||||
|
override fun putBoolean(key: String, value: Boolean) = apply { changes[key] = value.toString() }
|
||||||
|
override fun putInt(key: String, value: Int) = apply { changes[key] = value.toString() }
|
||||||
|
override fun putString(key: String, value: String) = apply { changes[key] = value }
|
||||||
|
override fun apply() = save(changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
package org.maturita.maturita.desktop
|
||||||
|
|
||||||
|
import org.maturita.maturita.data.ThemeId
|
||||||
|
import org.maturita.maturita.data.themeNames
|
||||||
|
import org.maturita.maturita.platform.Prefs
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GTK builds kept progress in `progress/<course>.conf` key files. Copy it
|
||||||
|
* once into the shared progress keys (`net.3`, `en.2.5`, `g.0.vocab` …); the
|
||||||
|
* shared ProgressStore then moves those onto lesson ids like on every
|
||||||
|
* platform. The .conf files stay, so nothing is lost if the import misreads.
|
||||||
|
*/
|
||||||
|
fun importGtkProgress(dir: File, prefs: Prefs) {
|
||||||
|
if (prefs.getBoolean("gtk_imported", false)) return
|
||||||
|
val edit = prefs.edit()
|
||||||
|
for (file in dir.listFiles { f -> f.name.endsWith(".conf") }.orEmpty()) {
|
||||||
|
val course = file.name.removeSuffix(".conf")
|
||||||
|
val groups = readKeyFile(file)
|
||||||
|
if (course == "settings") {
|
||||||
|
groups["ui"]?.let { importSettings(it, edit) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for ((key, value) in groups["done"].orEmpty()) {
|
||||||
|
if (value != "true") continue
|
||||||
|
gtkAddress(course, key)?.let { edit.putBoolean(it, true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edit.putBoolean("gtk_imported", true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GTK file and key to the address the apps use, or null for what has no equivalent. */
|
||||||
|
internal fun gtkAddress(course: String, key: String): String? {
|
||||||
|
val unit = Regex("""unit(\d)""").matchEntire(course)?.groupValues?.get(1)?.toInt()
|
||||||
|
return when {
|
||||||
|
// The vocabulary branch used the last slot of the unit.
|
||||||
|
unit != null -> key.toIntOrNull()?.let { if (it == 20) "g.${unit - 1}.vocab" else "g.${unit - 1}.$it" }
|
||||||
|
course == "english" -> yearKey("en", key)
|
||||||
|
course == "german-course" -> yearKey("de", key)
|
||||||
|
course == "mluvnice" -> key.toIntOrNull()?.let { "mluv.$it" }
|
||||||
|
// Book progress was one shared slot, not per book.
|
||||||
|
course == "cetba" -> null
|
||||||
|
else -> key.toIntOrNull()?.let { "$course.$it" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun yearKey(prefix: String, key: String): String? {
|
||||||
|
val (year, lesson) = key.split("-").takeIf { it.size == 2 } ?: return null
|
||||||
|
if (year.toIntOrNull() == null || lesson.toIntOrNull() == null) return null
|
||||||
|
return "$prefix.$year.$lesson"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importSettings(ui: Map<String, String>, edit: Prefs.Editor) {
|
||||||
|
ui["theme"]?.let { name ->
|
||||||
|
val i = themeNames.indexOfFirst { it.equals(name, ignoreCase = true) }
|
||||||
|
if (i >= 0) edit.putInt("theme", ThemeId.entries[i].ordinal)
|
||||||
|
}
|
||||||
|
ui["mode"]?.let { edit.putString("mode", if (it.lowercase() in setOf("light", "white")) "light" else "dark") }
|
||||||
|
ui["lang"]?.let { edit.putString("lang", if (it.lowercase() in setOf("en", "english")) "en" else "cs") }
|
||||||
|
ui["seen_commit"]?.takeIf { it.isNotBlank() }?.let { edit.putString("seen_commit", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun readKeyFile(file: File): Map<String, Map<String, String>> {
|
||||||
|
val groups = LinkedHashMap<String, MutableMap<String, String>>()
|
||||||
|
var current: MutableMap<String, String>? = null
|
||||||
|
runCatching { file.readLines() }.getOrDefault(emptyList()).forEach { raw ->
|
||||||
|
val line = raw.trim()
|
||||||
|
when {
|
||||||
|
line.isEmpty() || line.startsWith("#") -> {}
|
||||||
|
line.startsWith("[") && line.endsWith("]") ->
|
||||||
|
current = groups.getOrPut(line.substring(1, line.length - 1)) { LinkedHashMap() }
|
||||||
|
'=' in line -> current?.put(line.substringBefore('=').trim(), line.substringAfter('=').trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GTK updater copied new packages over the install without deleting
|
||||||
|
* anything, so a Windows folder updated from GTK still carries its DLLs and
|
||||||
|
* data. style.css marks such a folder; jpackage never writes one.
|
||||||
|
*/
|
||||||
|
fun removeGtkLeftovers(install: Install) {
|
||||||
|
if (install.kind != Install.Kind.WindowsDir) return
|
||||||
|
val dir = install.target ?: return
|
||||||
|
val marker = File(dir, "style.css")
|
||||||
|
if (!marker.isFile) return
|
||||||
|
dir.listFiles().orEmpty().forEach { f ->
|
||||||
|
val name = f.name.lowercase()
|
||||||
|
val gtkDll = name.endsWith(".dll") && (name.startsWith("lib") || name == "zlib1.dll")
|
||||||
|
val stale = (f.isFile && (gtkDll || name == "changelog.txt")) ||
|
||||||
|
(f.isDirectory && name in setOf("lib", "share", "icons"))
|
||||||
|
if (stale) f.deleteRecursively()
|
||||||
|
}
|
||||||
|
marker.delete()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package org.maturita.maturita.desktop
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this copy runs, which decides where progress lives and what an update
|
||||||
|
* replaces. Progress sits next to the app, as it did in the GTK builds, so
|
||||||
|
* existing students keep it: beside the AppImage on Linux, inside the install
|
||||||
|
* directory on Windows.
|
||||||
|
*/
|
||||||
|
class Install(
|
||||||
|
val kind: Kind,
|
||||||
|
/** The AppImage file, or the Windows directory holding graduately.exe. */
|
||||||
|
val target: File?,
|
||||||
|
val progressDir: File,
|
||||||
|
) {
|
||||||
|
enum class Kind { AppImage, WindowsDir, Unsupported }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val isWindows = System.getProperty("os.name").orEmpty().startsWith("Windows")
|
||||||
|
|
||||||
|
fun detect(): Install {
|
||||||
|
val appImage = System.getenv("APPIMAGE")?.takeIf { it.isNotBlank() }?.let(::File)
|
||||||
|
val launcher = System.getProperty("jpackage.app-path")?.let(::File)
|
||||||
|
val windowsDir = launcher?.parentFile?.takeIf {
|
||||||
|
isWindows && launcher.name.equals("graduately.exe", ignoreCase = true) &&
|
||||||
|
File(it, "app").isDirectory && File(it, "runtime").isDirectory
|
||||||
|
}
|
||||||
|
val (kind, target) = when {
|
||||||
|
appImage != null && !isWindows -> Kind.AppImage to appImage
|
||||||
|
windowsDir != null -> Kind.WindowsDir to windowsDir
|
||||||
|
else -> Kind.Unsupported to null
|
||||||
|
}
|
||||||
|
val beside = target?.let { if (kind == Kind.AppImage) it.parentFile else it }
|
||||||
|
val home = File(System.getProperty("user.home"), ".graduately")
|
||||||
|
val root = beside?.takeIf { it.canWrite() } ?: home
|
||||||
|
return Install(kind, target, File(root, "progress"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
package org.maturita.maturita.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.isCtrlPressed
|
||||||
|
import androidx.compose.ui.input.key.isMetaPressed
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.input.pointer.PointerButton
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.application
|
||||||
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import org.maturita.maturita.AppViewModel
|
||||||
|
import org.maturita.maturita.MaturitaApp
|
||||||
|
import org.maturita.maturita.data.Content
|
||||||
|
import org.maturita.maturita.handleBack
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Properties
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
|
private fun resourceText(name: String): String? =
|
||||||
|
Thread.currentThread().contextClassLoader.getResourceAsStream(name)?.bufferedReader()?.use { it.readText() }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
fun main() {
|
||||||
|
val install = Install.detect()
|
||||||
|
val prefs = FilePrefs(File(install.progressDir, "graduately.properties"))
|
||||||
|
importGtkProgress(install.progressDir, prefs)
|
||||||
|
removeGtkLeftovers(install)
|
||||||
|
|
||||||
|
val info = Properties().apply { resourceText("build-info.properties")?.let { load(it.reader()) } }
|
||||||
|
val platform = DesktopPlatform(info.getProperty("commit") ?: "dev", prefs, install)
|
||||||
|
val vm = AppViewModel(Content.parse(resourceText("content.json")), platform)
|
||||||
|
// `GRADUATELY_PAGE=u1e3 make run` opens a page, for screenshots.
|
||||||
|
System.getenv("GRADUATELY_PAGE")?.takeIf { it.isNotBlank() }?.let { vm.openPage(it) }
|
||||||
|
val icon = Thread.currentThread().contextClassLoader.getResourceAsStream("icon.png")
|
||||||
|
?.use { ImageIO.read(it) }?.let { BitmapPainter(it.toComposeImageBitmap()) }
|
||||||
|
|
||||||
|
application {
|
||||||
|
Window(
|
||||||
|
onCloseRequest = ::exitApplication,
|
||||||
|
title = "Graduately",
|
||||||
|
icon = icon,
|
||||||
|
state = rememberWindowState(width = 1100.dp, height = 780.dp),
|
||||||
|
onPreviewKeyEvent = { e ->
|
||||||
|
if (e.type != KeyEventType.KeyDown) return@Window false
|
||||||
|
val cmd = e.isCtrlPressed || e.isMetaPressed
|
||||||
|
when {
|
||||||
|
e.key == Key.Escape -> vm.handleBack()
|
||||||
|
cmd && e.key == Key.K -> { vm.searchOpen = true; true }
|
||||||
|
cmd && e.key == Key.Q -> { exitApplication(); true }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().onPointerEvent(PointerEventType.Press) { e ->
|
||||||
|
if (e.button == PointerButton.Back) vm.handleBack()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
MaturitaApp(vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package org.maturita.maturita
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.maturita.maturita.data.answerAccepts
|
||||||
|
import org.maturita.maturita.data.answerIncomplete
|
||||||
|
import org.maturita.maturita.data.hasUmlaut
|
||||||
|
import org.maturita.maturita.data.mathAnswerOk
|
||||||
|
import org.maturita.maturita.data.mathDrawGiven
|
||||||
|
import org.maturita.maturita.data.mathDrawNeed
|
||||||
|
import org.maturita.maturita.data.mathDrawOk
|
||||||
|
import org.maturita.maturita.data.mathIsDraw
|
||||||
|
import org.maturita.maturita.data.netTxtEq
|
||||||
|
import org.maturita.maturita.data.normalizeAnswer
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/** Runs tests/answers.json, the cases the Swift apps check too (tests/swift). */
|
||||||
|
class AnswersTest {
|
||||||
|
private val cases = JSONObject(File(System.getProperty("graduately.repo"), "tests/answers.json").readText())
|
||||||
|
|
||||||
|
private fun rows(name: String): List<JSONArray> =
|
||||||
|
cases.getJSONArray(name).let { a -> (0 until a.length()).map { a.getJSONArray(it) } }
|
||||||
|
|
||||||
|
private fun JSONArray.str(i: Int): String? = if (isNull(i)) null else getString(i)
|
||||||
|
|
||||||
|
private fun JSONArray.points(i: Int): List<Pair<Int, Int>> =
|
||||||
|
getJSONArray(i).let { a -> (0 until a.length()).map { a.getJSONArray(it).let { p -> p.getInt(0) to p.getInt(1) } } }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalize() = rows("normalize").forEach { assertEquals(it.str(1), normalizeAnswer(it.str(0)), "normalize ${it.str(0)}") }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun accepts() = rows("accepts").forEach {
|
||||||
|
assertEquals(it.getBoolean(2), answerAccepts(normalizeAnswer(it.str(0)), it.str(1)), "accepts $it")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun incomplete() = rows("incomplete").forEach {
|
||||||
|
assertEquals(it.getBoolean(2), answerIncomplete(normalizeAnswer(it.str(0)), it.str(1)), "incomplete $it")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun netText() = rows("netTxtEq").forEach { assertEquals(it.getBoolean(2), netTxtEq(it.str(0), it.str(1)), "netTxtEq $it") }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun umlaut() = rows("hasUmlaut").forEach { assertEquals(it.getBoolean(1), hasUmlaut(it.str(0)), "hasUmlaut $it") }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun math() = rows("math").forEach { assertEquals(it.getBoolean(2), mathAnswerOk(it.str(0), it.str(1)), "math $it") }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun draw() {
|
||||||
|
rows("drawNeed").forEach { assertEquals(it.getInt(1), mathDrawNeed(it.str(0)), "drawNeed $it") }
|
||||||
|
rows("drawGiven").forEach { assertEquals(it.points(1), mathDrawGiven(it.str(0)), "drawGiven $it") }
|
||||||
|
rows("draw").forEach { assertEquals(it.getBoolean(2), mathDrawOk(it.str(0), it.points(1)), "draw $it") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every worked answer in content/ must pass its own check, or students could never solve it. */
|
||||||
|
@Test
|
||||||
|
fun contentAnswersSolveThemselves() {
|
||||||
|
val root = JSONObject(javaClass.classLoader.getResource("content.json")!!.readText())
|
||||||
|
var checked = 0
|
||||||
|
for (section in listOf("mat0", "mat", "mat2", "mat3", "mat4")) {
|
||||||
|
val lessons = root.getJSONArray(section)
|
||||||
|
for (l in 0 until lessons.length()) {
|
||||||
|
val lesson = lessons.getJSONObject(l)
|
||||||
|
val problems = lesson.getJSONArray("problems")
|
||||||
|
for (p in 0 until problems.length()) {
|
||||||
|
val spec = problems.getJSONObject(p).getString("answer")
|
||||||
|
val where = "$section ${lesson.getString("key")}: $spec"
|
||||||
|
if (mathIsDraw(spec)) {
|
||||||
|
assertTrue(mathDrawNeed(spec) > 0, where)
|
||||||
|
} else {
|
||||||
|
val body = spec.substringAfter(':').substringBefore('|')
|
||||||
|
assertTrue(mathAnswerOk(spec, body), where)
|
||||||
|
}
|
||||||
|
checked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(checked > 100, "only $checked problems found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package org.maturita.maturita
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.maturita.maturita.data.ColorMode
|
||||||
|
import org.maturita.maturita.data.Content
|
||||||
|
import org.maturita.maturita.data.ProgressStore
|
||||||
|
import org.maturita.maturita.data.ThemeId
|
||||||
|
import org.maturita.maturita.desktop.FilePrefs
|
||||||
|
import org.maturita.maturita.desktop.gtkAddress
|
||||||
|
import org.maturita.maturita.desktop.importGtkProgress
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ProgressTest {
|
||||||
|
private val content = Content.parse(javaClass.classLoader.getResource("content.json")!!.readText())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyLegacyAddressHasALesson() {
|
||||||
|
val root = JSONObject(javaClass.classLoader.getResource("content.json")!!.readText())
|
||||||
|
val keys = root.getJSONObject("progress").getJSONObject("keys")
|
||||||
|
assertTrue(content.progressLegacy.isNotEmpty())
|
||||||
|
for ((old, id) in content.progressLegacy) {
|
||||||
|
assertTrue(keys.keySet().any { keys.getString(it) == id }, "$old -> $id has no lesson")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oldProgressMovesToLessonIds() {
|
||||||
|
val dir = Files.createTempDirectory("progress").toFile()
|
||||||
|
val prefs = FilePrefs(dir.resolve("p.properties"))
|
||||||
|
prefs.edit().putBoolean("fyz.1", true).putBoolean("g.0.vocab", true).putBoolean("en.2.3", true).apply()
|
||||||
|
|
||||||
|
val store = ProgressStore(prefs, content)
|
||||||
|
assertTrue(store.fyzDone(1))
|
||||||
|
assertTrue(store.vocabDone(0))
|
||||||
|
assertTrue(store.enDone(2, 3))
|
||||||
|
assertFalse(store.fyzDone(2))
|
||||||
|
assertTrue(prefs.getBoolean("done.fyzika-1-fyzikalni-veliciny", false))
|
||||||
|
|
||||||
|
store.markMat(1)
|
||||||
|
assertTrue(FilePrefs(dir.resolve("p.properties")).getBoolean("done.matematika-1-delitelnost-a-prvocisla", false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gtkProgressIsImported() {
|
||||||
|
val dir = Files.createTempDirectory("gtk").toFile()
|
||||||
|
dir.resolve("net.conf").writeText("[done]\n1=true\n2=false\n")
|
||||||
|
dir.resolve("unit1.conf").writeText("[done]\n1=true\n20=true\n")
|
||||||
|
dir.resolve("english.conf").writeText("[done]\n2-3=true\n")
|
||||||
|
dir.resolve("settings.conf").writeText("[ui]\ntheme=Nord\nmode=dark\nlang=en\nseen_commit=abcdef1\n")
|
||||||
|
val prefs = FilePrefs(dir.resolve("graduately.properties"))
|
||||||
|
|
||||||
|
importGtkProgress(dir, prefs)
|
||||||
|
val store = ProgressStore(prefs, content)
|
||||||
|
|
||||||
|
assertTrue(store.netDone(1))
|
||||||
|
assertFalse(store.netDone(2))
|
||||||
|
assertTrue(store.germanDone(0, 1))
|
||||||
|
assertTrue(store.vocabDone(0))
|
||||||
|
assertTrue(store.enDone(2, 3))
|
||||||
|
assertEquals(ThemeId.Nord, store.themeId)
|
||||||
|
assertEquals(ColorMode.Dark, store.mode)
|
||||||
|
assertEquals("abcdef1", store.seenCommit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gtkAddresses() {
|
||||||
|
assertEquals("g.2.5", gtkAddress("unit3", "5"))
|
||||||
|
assertEquals("de.4.6", gtkAddress("german-course", "4-6"))
|
||||||
|
assertEquals("mluv.7", gtkAddress("mluvnice", "7"))
|
||||||
|
assertEquals("hw3.2", gtkAddress("hw3", "2"))
|
||||||
|
assertEquals(null, gtkAddress("cetba", "1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
5
android/gradle.properties
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
org.gradle.parallel=true
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=60000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
80
android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,80 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
app_path=$0
|
||||||
|
while [ -h "$app_path" ]; do
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"}
|
||||||
|
ls=$( ls -ld -- "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in
|
||||||
|
/*) app_path=$link ;;
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
APP_HOME=$( cd "${app_path%"${app_path##*/}"}" && pwd -P ) || exit
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () { echo "$*"; } >&2
|
||||||
|
die () { echo; echo "$*"; echo; exit 1; } >&2
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
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"
|
||||||
|
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."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in
|
||||||
|
max*) MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" ;;
|
||||||
|
esac
|
||||||
|
case $MAX_FD in
|
||||||
|
'' | soft) : ;;
|
||||||
|
*) ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=gradlew" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
22
android/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
exit /b 1
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
exit /b 1
|
||||||
|
:execute
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
BIN
android/keystore/sideload.jks
Normal file
19
android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "maturita"
|
||||||
|
include(":app")
|
||||||
|
include(":desktop")
|
||||||
804
android/shared/src/org/maturita/maturita/App.kt
Normal file
|
|
@ -0,0 +1,804 @@
|
||||||
|
package org.maturita.maturita
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import org.maturita.maturita.data.ColorMode
|
||||||
|
import org.maturita.maturita.data.Palette
|
||||||
|
import org.maturita.maturita.ui.BookListScreen
|
||||||
|
import org.maturita.maturita.ui.BookScreen
|
||||||
|
import org.maturita.maturita.ui.CodeLessonScreen
|
||||||
|
import org.maturita.maturita.ui.CsYearsScreen
|
||||||
|
import org.maturita.maturita.ui.CzechMapScreen
|
||||||
|
import org.maturita.maturita.ui.GermanExercise
|
||||||
|
import org.maturita.maturita.ui.HomeScreen
|
||||||
|
import org.maturita.maturita.ui.HwQuizScreen
|
||||||
|
import org.maturita.maturita.ui.HwYearsScreen
|
||||||
|
import org.maturita.maturita.ui.LessonListScreen
|
||||||
|
import org.maturita.maturita.ui.FyzQuizScreen
|
||||||
|
import org.maturita.maturita.ui.PgQuizScreen
|
||||||
|
import org.maturita.maturita.ui.PgYearsScreen
|
||||||
|
import org.maturita.maturita.ui.SwQuizScreen
|
||||||
|
import org.maturita.maturita.ui.SwYearsScreen
|
||||||
|
import org.maturita.maturita.ui.TgQuizScreen
|
||||||
|
import org.maturita.maturita.ui.TgYearsScreen
|
||||||
|
import org.maturita.maturita.ui.MathPracticeScreen
|
||||||
|
import org.maturita.maturita.ui.MatYearsScreen
|
||||||
|
import org.maturita.maturita.ui.FyzYearsScreen
|
||||||
|
import org.maturita.maturita.ui.LitYearsScreen
|
||||||
|
import org.maturita.maturita.ui.LessonRow
|
||||||
|
import org.maturita.maturita.ui.LitQuizScreen
|
||||||
|
import org.maturita.maturita.ui.SciMapScreen
|
||||||
|
import org.maturita.maturita.ui.SciQuizScreen
|
||||||
|
import org.maturita.maturita.ui.MluvExercise
|
||||||
|
import org.maturita.maturita.ui.NetQuizScreen
|
||||||
|
import org.maturita.maturita.ui.NetYearsScreen
|
||||||
|
import org.maturita.maturita.ui.On2QuizScreen
|
||||||
|
import org.maturita.maturita.ui.On3QuizScreen
|
||||||
|
import org.maturita.maturita.ui.On4QuizScreen
|
||||||
|
import org.maturita.maturita.ui.OnQuizScreen
|
||||||
|
import org.maturita.maturita.ui.OnYearsScreen
|
||||||
|
import org.maturita.maturita.ui.EnYearsScreen
|
||||||
|
import org.maturita.maturita.ui.ChoiceLessonScreen
|
||||||
|
import org.maturita.maturita.ui.DeHomeScreen
|
||||||
|
import org.maturita.maturita.ui.ItYearScreen
|
||||||
|
import org.maturita.maturita.ui.EngExScreen
|
||||||
|
import org.maturita.maturita.ui.PlotScreen
|
||||||
|
import org.maturita.maturita.ui.RoadmapScreen
|
||||||
|
import org.maturita.maturita.ui.SearchScreen
|
||||||
|
import org.maturita.maturita.ui.SettingsScreen
|
||||||
|
import org.maturita.maturita.ui.SlidesScreen
|
||||||
|
import org.maturita.maturita.ui.StatsScreen
|
||||||
|
import org.maturita.maturita.ui.UnitMapScreen
|
||||||
|
import org.maturita.maturita.ui.VocabExercise
|
||||||
|
import org.maturita.maturita.ui.appTypography
|
||||||
|
|
||||||
|
/** Material 3 colour roles from the shared theme palette. */
|
||||||
|
private fun schemeFor(p: Palette, dark: Boolean) = if (dark) {
|
||||||
|
darkColorScheme(
|
||||||
|
primary = p.tint,
|
||||||
|
onPrimary = p.onTint,
|
||||||
|
primaryContainer = p.accent,
|
||||||
|
onPrimaryContainer = p.onAccent,
|
||||||
|
secondary = p.accent2,
|
||||||
|
onSecondary = p.onAccent,
|
||||||
|
secondaryContainer = p.surface1,
|
||||||
|
onSecondaryContainer = p.text,
|
||||||
|
tertiary = p.warning,
|
||||||
|
onTertiary = p.crust,
|
||||||
|
background = p.base,
|
||||||
|
onBackground = p.text,
|
||||||
|
surface = p.base,
|
||||||
|
onSurface = p.text,
|
||||||
|
surfaceVariant = p.surface0,
|
||||||
|
onSurfaceVariant = p.subtext,
|
||||||
|
surfaceContainerLowest = p.crust,
|
||||||
|
surfaceContainerLow = p.mantle,
|
||||||
|
surfaceContainer = p.mantle,
|
||||||
|
surfaceContainerHigh = p.surface0,
|
||||||
|
surfaceContainerHighest = p.surface1,
|
||||||
|
outline = p.overlay,
|
||||||
|
outlineVariant = p.surface1,
|
||||||
|
error = p.error,
|
||||||
|
onError = Color.White,
|
||||||
|
errorContainer = p.error.copy(alpha = 0.22f),
|
||||||
|
onErrorContainer = p.text,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
lightColorScheme(
|
||||||
|
primary = p.tint,
|
||||||
|
onPrimary = p.onTint,
|
||||||
|
primaryContainer = p.accent,
|
||||||
|
onPrimaryContainer = p.onAccent,
|
||||||
|
secondary = p.accent2,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
secondaryContainer = p.surface1,
|
||||||
|
onSecondaryContainer = p.text,
|
||||||
|
tertiary = p.warning,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
background = p.base,
|
||||||
|
onBackground = p.text,
|
||||||
|
surface = p.base,
|
||||||
|
onSurface = p.text,
|
||||||
|
surfaceVariant = p.surface1,
|
||||||
|
onSurfaceVariant = p.subtext,
|
||||||
|
surfaceContainerLowest = p.mantle,
|
||||||
|
surfaceContainerLow = p.mantle,
|
||||||
|
surfaceContainer = p.mantle,
|
||||||
|
surfaceContainerHigh = p.surface1,
|
||||||
|
surfaceContainerHighest = p.surface2,
|
||||||
|
outline = p.overlay,
|
||||||
|
outlineVariant = p.surface2,
|
||||||
|
error = p.error,
|
||||||
|
onError = Color.White,
|
||||||
|
errorContainer = p.error.copy(alpha = 0.14f),
|
||||||
|
onErrorContainer = p.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MaturitaApp(vm: AppViewModel) {
|
||||||
|
MaterialTheme(colorScheme = schemeFor(vm.palette, vm.mode == ColorMode.Dark), typography = appTypography()) {
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
MaturitaAppBody(vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back gesture, Escape or the mouse back button: close search first, then walk up. */
|
||||||
|
fun AppViewModel.handleBack(): Boolean {
|
||||||
|
if (searchOpen) {
|
||||||
|
searchOpen = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!canGoBack) return false
|
||||||
|
back()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MaturitaAppBody(vm: AppViewModel) {
|
||||||
|
LaunchedEffect(Unit) { vm.checkUpdate(false) }
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = vm.route,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
transitionSpec = {
|
||||||
|
val tabs = targetState.isRoot && initialState.isRoot
|
||||||
|
if (tabs) {
|
||||||
|
fadeIn(tween(180)) togetherWith fadeOut(tween(140))
|
||||||
|
} else if (vm.navigatingForward) {
|
||||||
|
(fadeIn(tween(220)) + slideInHorizontally(tween(220)) { it / 5 })
|
||||||
|
.togetherWith(fadeOut(tween(150)) + slideOutHorizontally(tween(150)) { -it / 8 })
|
||||||
|
} else {
|
||||||
|
(fadeIn(tween(220)) + slideInHorizontally(tween(220)) { -it / 8 })
|
||||||
|
.togetherWith(fadeOut(tween(150)) + slideOutHorizontally(tween(150)) { it / 5 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = "screen",
|
||||||
|
) { route ->
|
||||||
|
when (val r = route) {
|
||||||
|
Route.Welcome, Route.Subjects -> HomeScreen(vm)
|
||||||
|
Route.Stats -> StatsScreen(vm)
|
||||||
|
Route.Settings -> SettingsScreen(vm)
|
||||||
|
Route.DeHome -> DeHomeScreen(vm)
|
||||||
|
is Route.DeMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("de_year${r.year}"), vm.tr("de_y${r.year}_sub"),
|
||||||
|
vm.content.deYear(r.year).map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.deDone(r.year, id), Route.DeLesson(r.year, id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.DeLesson -> {
|
||||||
|
val l = vm.content.deLesson(r.year, r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.DeEx(r.year, r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.DeEx -> EngExScreen(
|
||||||
|
vm, r.year, r.id, Locale.GERMANY, vm.content.deLesson(r.year, r.id),
|
||||||
|
) { vm.markDe(r.year, r.id) }
|
||||||
|
Route.Roadmap -> RoadmapScreen(vm)
|
||||||
|
is Route.UnitMap -> UnitMapScreen(vm, r.unitId)
|
||||||
|
is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex)
|
||||||
|
is Route.Vocab -> VocabExercise(vm, r.unitId)
|
||||||
|
Route.NetYears -> NetYearsScreen(vm)
|
||||||
|
Route.NetMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("net_year1"), vm.tr("net_sub"),
|
||||||
|
vm.content.netLessons.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.netDone(id), Route.NetLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.NetLesson -> {
|
||||||
|
val l = vm.content.netLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.NetEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.NetEx -> NetQuizScreen(vm, r.id)
|
||||||
|
Route.Net2Map -> ItYearScreen(vm, "net2", "net_year2", "net2_sub") { Route.Net2Lesson(it) }
|
||||||
|
is Route.Net2Lesson -> {
|
||||||
|
val l = vm.content.itLesson("net2", r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Net2Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Net2Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("net2", r.id)) { vm.markCourse("net2", r.id) }
|
||||||
|
Route.Net3Map -> ItYearScreen(vm, "net3", "net_year3", "net3_sub") { Route.Net3Lesson(it) }
|
||||||
|
is Route.Net3Lesson -> {
|
||||||
|
val l = vm.content.itLesson("net3", r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Net3Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Net3Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("net3", r.id)) { vm.markCourse("net3", r.id) }
|
||||||
|
Route.Net4Map -> ItYearScreen(vm, "net4", "net_year4", "net4_sub") { Route.Net4Lesson(it) }
|
||||||
|
is Route.Net4Lesson -> {
|
||||||
|
val l = vm.content.itLesson("net4", r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Net4Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Net4Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("net4", r.id)) { vm.markCourse("net4", r.id) }
|
||||||
|
Route.HwYears -> HwYearsScreen(vm)
|
||||||
|
Route.HwMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("hw_year1"), vm.tr("hw_sub"),
|
||||||
|
vm.content.hw.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.hwDone(id), Route.HwLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.HwLesson -> {
|
||||||
|
val l = vm.content.hwLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.HwEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.HwEx -> HwQuizScreen(vm, r.id)
|
||||||
|
Route.Hw2Map -> ItYearScreen(vm, "hw2", "hw_year2", "hw2_sub") { Route.Hw2Lesson(it) }
|
||||||
|
is Route.Hw2Lesson -> {
|
||||||
|
val l = vm.content.itLesson("hw2", r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Hw2Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Hw2Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("hw2", r.id)) { vm.markCourse("hw2", r.id) }
|
||||||
|
Route.Hw3Map -> ItYearScreen(vm, "hw3", "hw_year3", "hw3_sub") { Route.Hw3Lesson(it) }
|
||||||
|
is Route.Hw3Lesson -> {
|
||||||
|
val l = vm.content.itLesson("hw3", r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Hw3Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Hw3Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("hw3", r.id)) { vm.markCourse("hw3", r.id) }
|
||||||
|
Route.Hw4Map -> ItYearScreen(vm, "hw4", "hw_year4", "hw4_sub") { Route.Hw4Lesson(it) }
|
||||||
|
is Route.Hw4Lesson -> {
|
||||||
|
val l = vm.content.itLesson("hw4", r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Hw4Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Hw4Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("hw4", r.id)) { vm.markCourse("hw4", r.id) }
|
||||||
|
Route.EnYears -> EnYearsScreen(vm)
|
||||||
|
is Route.EnMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("en_year${r.year}"), vm.tr("en_y${r.year}_sub"),
|
||||||
|
vm.content.enYear(r.year).map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.enDone(r.year, id), Route.EnLesson(r.year, id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.EnLesson -> {
|
||||||
|
val l = vm.content.enLesson(r.year, r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.EnEx(r.year, r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.EnEx -> EngExScreen(vm, r.year, r.id)
|
||||||
|
Route.OnYears -> OnYearsScreen(vm)
|
||||||
|
Route.OnMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("on_year1"), vm.tr("on_sub"),
|
||||||
|
vm.content.on.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.onDone(id), Route.OnLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.OnLesson -> {
|
||||||
|
val l = vm.content.onLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.OnEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.OnEx -> OnQuizScreen(vm, r.id)
|
||||||
|
Route.On2Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("on_year2"), vm.tr("on2_sub"),
|
||||||
|
vm.content.on2.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.on2Done(id), Route.On2Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.On2Lesson -> {
|
||||||
|
val l = vm.content.on2Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.On2Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.On2Ex -> On2QuizScreen(vm, r.id)
|
||||||
|
Route.On3Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("on_year3"), vm.tr("on3_sub"),
|
||||||
|
vm.content.on3.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.on3Done(id), Route.On3Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.On3Lesson -> {
|
||||||
|
val l = vm.content.on3Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.On3Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.On3Ex -> On3QuizScreen(vm, r.id)
|
||||||
|
Route.On4Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("on_year4"), vm.tr("on4_sub"),
|
||||||
|
vm.content.on4.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.on4Done(id), Route.On4Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.On4Lesson -> {
|
||||||
|
val l = vm.content.on4Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.On4Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.On4Ex -> On4QuizScreen(vm, r.id)
|
||||||
|
Route.LitYears -> LitYearsScreen(vm)
|
||||||
|
Route.LitMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("lit_year1"), vm.tr("lit_sub"),
|
||||||
|
vm.content.lit.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.litDone(id), Route.LitLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.LitLesson -> {
|
||||||
|
val l = vm.content.litLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.LitEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.LitEx -> LitQuizScreen(vm, 1, r.id)
|
||||||
|
Route.Lit2Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("lit_year2"), vm.tr("lit2_sub"),
|
||||||
|
vm.content.lit2.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.lit2Done(id), Route.Lit2Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Lit2Lesson -> {
|
||||||
|
val l = vm.content.lit2Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Lit2Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Lit2Ex -> LitQuizScreen(vm, 2, r.id)
|
||||||
|
Route.Lit3Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("lit_year3"), vm.tr("lit3_sub"),
|
||||||
|
vm.content.lit3.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.lit3Done(id), Route.Lit3Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Lit3Lesson -> {
|
||||||
|
val l = vm.content.lit3Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Lit3Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Lit3Ex -> LitQuizScreen(vm, 3, r.id)
|
||||||
|
Route.Lit4Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("lit_year4"), vm.tr("lit4_sub"),
|
||||||
|
vm.content.lit4.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.lit4Done(id), Route.Lit4Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Lit4Lesson -> {
|
||||||
|
val l = vm.content.lit4Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Lit4Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Lit4Ex -> LitQuizScreen(vm, 4, r.id)
|
||||||
|
Route.MatYears -> MatYearsScreen(vm)
|
||||||
|
Route.Mat0Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("mat0_year"), vm.tr("mat0_sub"),
|
||||||
|
vm.content.mat0.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat0Done(id), Route.Mat0Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Mat0Lesson -> {
|
||||||
|
val l = vm.content.mat0Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Mat0Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Mat0Ex -> MathPracticeScreen(vm, 0, r.id)
|
||||||
|
Route.MatMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("mat_year1"), vm.tr("mat_sub"),
|
||||||
|
vm.content.mat.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.matDone(id), Route.MatLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.MatLesson -> {
|
||||||
|
val l = vm.content.matLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.MatEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.MatEx -> MathPracticeScreen(vm, 1, r.id)
|
||||||
|
Route.Mat2Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("mat_year2"), vm.tr("mat2_sub"),
|
||||||
|
vm.content.mat2.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat2Done(id), Route.Mat2Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Mat2Lesson -> {
|
||||||
|
val l = vm.content.mat2Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Mat2Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Mat2Ex -> MathPracticeScreen(vm, 2, r.id)
|
||||||
|
Route.Mat3Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("mat_year3"), vm.tr("mat3_sub"),
|
||||||
|
vm.content.mat3.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat3Done(id), Route.Mat3Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Mat3Lesson -> {
|
||||||
|
val l = vm.content.mat3Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Mat3Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Mat3Ex -> MathPracticeScreen(vm, 3, r.id)
|
||||||
|
Route.Mat4Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("mat_year4"), vm.tr("mat4_sub"),
|
||||||
|
vm.content.mat4.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat4Done(id), Route.Mat4Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Mat4Lesson -> {
|
||||||
|
val l = vm.content.mat4Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Mat4Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Mat4Ex -> MathPracticeScreen(vm, 4, r.id)
|
||||||
|
Route.FyzYears -> FyzYearsScreen(vm)
|
||||||
|
Route.FyzMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("fyz_year1"), vm.tr("fyz_sub"),
|
||||||
|
vm.content.fyz.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyzDone(id), Route.FyzLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.FyzLesson -> {
|
||||||
|
val l = vm.content.fyzLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.FyzEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.FyzEx -> FyzQuizScreen(vm, 1, r.id)
|
||||||
|
Route.Fyz2Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("fyz_year2"), vm.tr("fyz2_sub"),
|
||||||
|
vm.content.fyz2.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyz2Done(id), Route.Fyz2Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Fyz2Lesson -> {
|
||||||
|
val l = vm.content.fyz2Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Fyz2Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Fyz2Ex -> FyzQuizScreen(vm, 2, r.id)
|
||||||
|
Route.Fyz3Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("fyz_year3"), vm.tr("fyz3_sub"),
|
||||||
|
vm.content.fyz3.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyz3Done(id), Route.Fyz3Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Fyz3Lesson -> {
|
||||||
|
val l = vm.content.fyz3Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Fyz3Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Fyz3Ex -> FyzQuizScreen(vm, 3, r.id)
|
||||||
|
Route.Fyz4Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("fyz_year4"), vm.tr("fyz4_sub"),
|
||||||
|
vm.content.fyz4.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyz4Done(id), Route.Fyz4Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Fyz4Lesson -> {
|
||||||
|
val l = vm.content.fyz4Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Fyz4Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Fyz4Ex -> FyzQuizScreen(vm, 4, r.id)
|
||||||
|
Route.TgYears -> TgYearsScreen(vm)
|
||||||
|
Route.TgMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("tg_year1"), vm.tr("tg_sub"),
|
||||||
|
vm.content.tg.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.tgDone(id), Route.TgLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.TgLesson -> {
|
||||||
|
val l = vm.content.tgLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.TgEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.TgEx -> TgQuizScreen(vm, 1, r.id)
|
||||||
|
Route.Tg2Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("tg_year2"), vm.tr("tg2_sub"),
|
||||||
|
vm.content.tg2.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.tg2Done(id), Route.Tg2Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Tg2Lesson -> {
|
||||||
|
val l = vm.content.tg2Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Tg2Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Tg2Ex -> TgQuizScreen(vm, 2, r.id)
|
||||||
|
Route.Tg3Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("tg_year3"), vm.tr("tg3_sub"),
|
||||||
|
vm.content.tg3.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.tg3Done(id), Route.Tg3Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Tg3Lesson -> {
|
||||||
|
val l = vm.content.tg3Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Tg3Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Tg3Ex -> TgQuizScreen(vm, 3, r.id)
|
||||||
|
Route.Tg4Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("tg_year4"), vm.tr("tg4_sub"),
|
||||||
|
vm.content.tg4.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.tg4Done(id), Route.Tg4Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Tg4Lesson -> {
|
||||||
|
val l = vm.content.tg4Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Tg4Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Tg4Ex -> TgQuizScreen(vm, 4, r.id)
|
||||||
|
Route.PgYears -> PgYearsScreen(vm)
|
||||||
|
Route.PgMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("pg_year1"), vm.tr("pg_sub"),
|
||||||
|
vm.content.pg.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.pgDone(id), Route.PgLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.PgLesson -> {
|
||||||
|
val l = vm.content.pgLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.PgEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.PgEx -> PgQuizScreen(vm, 1, r.id)
|
||||||
|
Route.Pg2Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("pg_year2"), vm.tr("pg2_sub"),
|
||||||
|
vm.content.pg2.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.pg2Done(id), Route.Pg2Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Pg2Lesson -> {
|
||||||
|
val l = vm.content.pg2Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Pg2Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Pg2Ex -> PgQuizScreen(vm, 2, r.id)
|
||||||
|
Route.Pg3Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("pg_year3"), vm.tr("pg3_sub"),
|
||||||
|
vm.content.pg3.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.pg3Done(id), Route.Pg3Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Pg3Lesson -> {
|
||||||
|
val l = vm.content.pg3Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Pg3Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Pg3Ex -> PgQuizScreen(vm, 3, r.id)
|
||||||
|
Route.Pg4Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("pg_year4"), vm.tr("pg4_sub"),
|
||||||
|
vm.content.pg4.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.pg4Done(id), Route.Pg4Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Pg4Lesson -> {
|
||||||
|
val l = vm.content.pg4Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Pg4Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Pg4Ex -> PgQuizScreen(vm, 4, r.id)
|
||||||
|
Route.SwYears -> SwYearsScreen(vm)
|
||||||
|
Route.SwMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("sw_year1"), vm.tr("sw_sub"),
|
||||||
|
vm.content.sw.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.swDone(id), Route.SwLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.SwLesson -> {
|
||||||
|
val l = vm.content.swLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.SwEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.SwEx -> SwQuizScreen(vm, 1, r.id)
|
||||||
|
Route.Sw2Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("sw_year2"), vm.tr("sw2_sub"),
|
||||||
|
vm.content.sw2.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.sw2Done(id), Route.Sw2Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Sw2Lesson -> {
|
||||||
|
val l = vm.content.sw2Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Sw2Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Sw2Ex -> SwQuizScreen(vm, 2, r.id)
|
||||||
|
Route.Sw3Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("sw_year3"), vm.tr("sw3_sub"),
|
||||||
|
vm.content.sw3.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.sw3Done(id), Route.Sw3Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Sw3Lesson -> {
|
||||||
|
val l = vm.content.sw3Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Sw3Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Sw3Ex -> SwQuizScreen(vm, 3, r.id)
|
||||||
|
Route.Sw4Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("sw_year4"), vm.tr("sw4_sub"),
|
||||||
|
vm.content.sw4.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.sw4Done(id), Route.Sw4Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Sw4Lesson -> {
|
||||||
|
val l = vm.content.sw4Lesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.Sw4Ex(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.Sw4Ex -> SwQuizScreen(vm, 4, r.id)
|
||||||
|
Route.CsYears -> CsYearsScreen(vm)
|
||||||
|
Route.CsMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("cs_year1"), vm.tr("cs_sub"),
|
||||||
|
vm.content.cs.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.csDone(id), Route.CsLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.CsLesson -> {
|
||||||
|
val l = vm.content.csLesson(r.id)
|
||||||
|
if (l != null) CodeLessonScreen(vm, l) { vm.markCs(r.id) }
|
||||||
|
}
|
||||||
|
Route.Cs2Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("cs_year2"), vm.tr("cs2_sub"),
|
||||||
|
vm.content.cs2.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.cs2Done(id), Route.Cs2Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Cs2Lesson -> {
|
||||||
|
val l = vm.content.cs2Lesson(r.id)
|
||||||
|
if (l != null) CodeLessonScreen(vm, l) { vm.markCs2(r.id) }
|
||||||
|
}
|
||||||
|
Route.Cs3Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("cs_year3"), vm.tr("cs3_sub"),
|
||||||
|
vm.content.cs3.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.cs3Done(id), Route.Cs3Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Cs3Lesson -> {
|
||||||
|
val l = vm.content.cs3Lesson(r.id)
|
||||||
|
if (l != null) CodeLessonScreen(vm, l) { vm.markCs3(r.id) }
|
||||||
|
}
|
||||||
|
Route.Cs4Map -> LessonListScreen(
|
||||||
|
vm, vm.tr("cs_year4"), vm.tr("cs4_sub"),
|
||||||
|
vm.content.cs4.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.cs4Done(id), Route.Cs4Lesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.Cs4Lesson -> {
|
||||||
|
val l = vm.content.cs4Lesson(r.id)
|
||||||
|
if (l != null) CodeLessonScreen(vm, l) { vm.markCs4(r.id) }
|
||||||
|
}
|
||||||
|
Route.SciMap -> SciMapScreen(vm)
|
||||||
|
Route.ChemMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("Chemie"), vm.tr("chem_sub"),
|
||||||
|
vm.content.chem.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.chemDone(id), Route.ChemLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.ChemLesson -> {
|
||||||
|
val l = vm.content.chemLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.ChemEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.ChemEx -> SciQuizScreen(vm, "chem", r.id)
|
||||||
|
Route.BioMap -> LessonListScreen(
|
||||||
|
vm, vm.tr("Biologie"), vm.tr("bio_sub"),
|
||||||
|
vm.content.bio.map { l ->
|
||||||
|
val id = l.int("id")
|
||||||
|
LessonRow(vm.tr(l.str("titleKey")), vm.progress.bioDone(id), Route.BioLesson(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.BioLesson -> {
|
||||||
|
val l = vm.content.bioLesson(r.id)
|
||||||
|
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
|
||||||
|
vm.replace(Route.BioEx(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Route.BioEx -> SciQuizScreen(vm, "bio", r.id)
|
||||||
|
Route.CzechMap -> CzechMapScreen(vm)
|
||||||
|
Route.Mluvnice -> LessonListScreen(
|
||||||
|
vm, vm.tr("Mluvnice"), vm.tr("mluv_sub"),
|
||||||
|
vm.content.mluvnice.map { m ->
|
||||||
|
val n = m.int("id")
|
||||||
|
LessonRow(m.str("name"), vm.progress.mluvDone(n), Route.MluvEx(n))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is Route.MluvEx -> MluvExercise(vm, r.n)
|
||||||
|
Route.ReadingList -> BookListScreen(vm)
|
||||||
|
is Route.Book -> BookScreen(vm, r.id)
|
||||||
|
is Route.BookQuiz -> LitQuizScreen(vm, r.id)
|
||||||
|
is Route.BookPlot -> PlotScreen(vm, r.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = vm.searchOpen,
|
||||||
|
enter = fadeIn(tween(160)),
|
||||||
|
exit = fadeOut(tween(120)),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) { SearchScreen(vm) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
364
android/shared/src/org/maturita/maturita/AppViewModel.kt
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
package org.maturita.maturita
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import org.maturita.maturita.data.ColorMode
|
||||||
|
import org.maturita.maturita.data.Content
|
||||||
|
import org.maturita.maturita.data.ProgressStore
|
||||||
|
import org.maturita.maturita.data.ThemeId
|
||||||
|
import org.maturita.maturita.data.UiLang
|
||||||
|
import org.maturita.maturita.data.themePalette
|
||||||
|
import org.maturita.maturita.platform.Platform
|
||||||
|
import org.maturita.maturita.platform.UpdateState
|
||||||
|
|
||||||
|
/** App state shared by every platform; the platform only supplies [Platform]. */
|
||||||
|
class AppViewModel(val content: Content, val platform: Platform) {
|
||||||
|
val progress = ProgressStore(platform.prefs, content)
|
||||||
|
private val updater = platform.updater
|
||||||
|
|
||||||
|
var themeId by mutableStateOf(progress.themeId)
|
||||||
|
private set
|
||||||
|
var mode by mutableStateOf(progress.mode)
|
||||||
|
private set
|
||||||
|
var lang by mutableStateOf(progress.lang)
|
||||||
|
private set
|
||||||
|
var searchOpen by mutableStateOf(false)
|
||||||
|
var searchQuery by mutableStateOf("")
|
||||||
|
var tick by mutableStateOf(0)
|
||||||
|
private set
|
||||||
|
var update by mutableStateOf(UpdateState(messageArg = platform.commit.take(7)))
|
||||||
|
private set
|
||||||
|
var showChangelog by mutableStateOf(shouldShowChangelog())
|
||||||
|
private set
|
||||||
|
/** True when the latest navigation moved deeper; false for back and tab switches. */
|
||||||
|
var navigatingForward by mutableStateOf(true)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val stack = mutableStateListOf<Route>(Route.Subjects)
|
||||||
|
val route: Route get() = stack.last()
|
||||||
|
val root: Route get() = stack.first()
|
||||||
|
val canGoBack: Boolean get() = stack.size > 1 || root != Route.Subjects
|
||||||
|
|
||||||
|
val palette get() = themePalette(themeId, mode)
|
||||||
|
|
||||||
|
fun tr(key: String?) = content.tr(key, lang)
|
||||||
|
fun fmt(key: String, vararg args: Any) = content.fmt(key, lang, *args)
|
||||||
|
|
||||||
|
/** Roots switch the bottom tab; everything else is pushed on the practice stack. */
|
||||||
|
fun go(r: Route) {
|
||||||
|
searchOpen = false
|
||||||
|
if (stack.last() == r) return
|
||||||
|
if (r.isRoot) {
|
||||||
|
navigatingForward = false
|
||||||
|
val target = if (r == Route.Welcome) Route.Subjects else r
|
||||||
|
stack.clear()
|
||||||
|
stack.add(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigatingForward = true
|
||||||
|
if (root != Route.Subjects) {
|
||||||
|
stack.clear()
|
||||||
|
stack.addAll(r.stack())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stack.add(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goPage(page: String?) {
|
||||||
|
val r = page?.let { routeFromPage(it) } ?: return
|
||||||
|
go(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jumping from search rebuilds the whole path so back walks up level by level. */
|
||||||
|
fun openPage(page: String) {
|
||||||
|
val r = routeFromPage(page) ?: return
|
||||||
|
searchOpen = false
|
||||||
|
navigatingForward = true
|
||||||
|
stack.clear()
|
||||||
|
stack.addAll(r.stack())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the current screen (lesson slides -> its exercise). */
|
||||||
|
fun replace(r: Route) {
|
||||||
|
navigatingForward = true
|
||||||
|
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||||
|
stack.add(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun back() {
|
||||||
|
navigatingForward = false
|
||||||
|
when {
|
||||||
|
stack.size > 1 -> stack.removeAt(stack.lastIndex)
|
||||||
|
root != Route.Subjects -> go(Route.Subjects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTheme(id: ThemeId) {
|
||||||
|
themeId = id
|
||||||
|
progress.themeId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyMode(m: ColorMode) {
|
||||||
|
mode = m
|
||||||
|
progress.mode = m
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyLang(l: UiLang) {
|
||||||
|
lang = l
|
||||||
|
progress.lang = l
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() { tick++ }
|
||||||
|
|
||||||
|
fun markGerman(unit: Int, ex: Int) {
|
||||||
|
progress.markGerman(unit, ex)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markVocab(unit: Int) {
|
||||||
|
progress.markVocab(unit)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markNet(id: Int) {
|
||||||
|
progress.markNet(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markHw(id: Int) {
|
||||||
|
progress.markHw(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markOn(id: Int) {
|
||||||
|
progress.markOn(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markOn2(id: Int) {
|
||||||
|
progress.markOn2(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markOn3(id: Int) {
|
||||||
|
progress.markOn3(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markOn4(id: Int) {
|
||||||
|
progress.markOn4(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markEn(year: Int, id: Int) {
|
||||||
|
progress.markEn(year, id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markDe(year: Int, id: Int) {
|
||||||
|
progress.markDe(year, id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markCourse(course: String, id: Int) {
|
||||||
|
progress.markCourse(course, id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markLit(id: Int) {
|
||||||
|
progress.markLit(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markLit2(id: Int) {
|
||||||
|
progress.markLit2(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markLit3(id: Int) {
|
||||||
|
progress.markLit3(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markLit4(id: Int) {
|
||||||
|
progress.markLit4(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markChem(id: Int) {
|
||||||
|
progress.markChem(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markBio(id: Int) {
|
||||||
|
progress.markBio(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markFyz(id: Int) {
|
||||||
|
progress.markFyz(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markFyz2(id: Int) {
|
||||||
|
progress.markFyz2(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markFyz3(id: Int) {
|
||||||
|
progress.markFyz3(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markMat0(id: Int) {
|
||||||
|
progress.markMat0(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markMat(id: Int) {
|
||||||
|
progress.markMat(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markMat2(id: Int) {
|
||||||
|
progress.markMat2(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markMat3(id: Int) {
|
||||||
|
progress.markMat3(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markMat4(id: Int) {
|
||||||
|
progress.markMat4(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markFyz4(id: Int) {
|
||||||
|
progress.markFyz4(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markTg(id: Int) {
|
||||||
|
progress.markTg(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markTg2(id: Int) {
|
||||||
|
progress.markTg2(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markTg3(id: Int) {
|
||||||
|
progress.markTg3(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markTg4(id: Int) {
|
||||||
|
progress.markTg4(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markPg(id: Int) {
|
||||||
|
progress.markPg(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markPg2(id: Int) {
|
||||||
|
progress.markPg2(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markPg3(id: Int) {
|
||||||
|
progress.markPg3(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markPg4(id: Int) {
|
||||||
|
progress.markPg4(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markSw(id: Int) {
|
||||||
|
progress.markSw(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markSw2(id: Int) {
|
||||||
|
progress.markSw2(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markSw3(id: Int) {
|
||||||
|
progress.markSw3(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markSw4(id: Int) {
|
||||||
|
progress.markSw4(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markCs(id: Int) {
|
||||||
|
progress.markCs(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markCs2(id: Int) {
|
||||||
|
progress.markCs2(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markCs3(id: Int) {
|
||||||
|
progress.markCs3(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markCs4(id: Int) {
|
||||||
|
progress.markCs4(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markMluv(n: Int) {
|
||||||
|
progress.markMluv(n)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markBookQuiz(id: String) {
|
||||||
|
progress.markBookQuiz(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markBookPlot(id: String) {
|
||||||
|
progress.markBookPlot(id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkUpdate(interactive: Boolean) {
|
||||||
|
updater.check(interactive) { update = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installUpdate() {
|
||||||
|
updater.install { update = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissChangelog() {
|
||||||
|
progress.seenCommit = platform.commit
|
||||||
|
showChangelog = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldShowChangelog(): Boolean {
|
||||||
|
if (content.changelog.isEmpty()) return false
|
||||||
|
return !sameBuild(platform.commit, progress.seenCommit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sameBuild(a: String?, b: String?): Boolean {
|
||||||
|
if (a.isNullOrEmpty() || b.isNullOrEmpty()) return false
|
||||||
|
if (a.equals(b, ignoreCase = true)) return true
|
||||||
|
val n = minOf(a.length, b.length)
|
||||||
|
return n >= 7 && a.regionMatches(0, b, 0, n, ignoreCase = true)
|
||||||
|
}
|
||||||
696
android/shared/src/org/maturita/maturita/Route.kt
Normal file
|
|
@ -0,0 +1,696 @@
|
||||||
|
package org.maturita.maturita
|
||||||
|
|
||||||
|
sealed class Route(val page: String) {
|
||||||
|
data object Welcome : Route("welcome")
|
||||||
|
data object Subjects : Route("subjects")
|
||||||
|
data object Stats : Route("stats")
|
||||||
|
data object Settings : Route("settings")
|
||||||
|
data object Roadmap : Route("roadmap")
|
||||||
|
data object DeHome : Route("dehome")
|
||||||
|
data class DeMap(val year: Int) : Route("de${year}map")
|
||||||
|
data class DeLesson(val year: Int, val id: Int) : Route("de${year}unit$id")
|
||||||
|
data class DeEx(val year: Int, val id: Int) : Route("de${year}ex$id")
|
||||||
|
data class UnitMap(val unitId: Int) : Route("unit${unitId + 1}")
|
||||||
|
data class GermanEx(val unitId: Int, val ex: Int) : Route("u${unitId + 1}e$ex")
|
||||||
|
data class Vocab(val unitId: Int) : Route("u${unitId + 1}vocab")
|
||||||
|
data object NetYears : Route("netyears")
|
||||||
|
data object NetMap : Route("netmap")
|
||||||
|
data class NetLesson(val id: Int) : Route("netunit$id")
|
||||||
|
data class NetEx(val id: Int) : Route("netex$id")
|
||||||
|
data object Net2Map : Route("net2map")
|
||||||
|
data class Net2Lesson(val id: Int) : Route("net2unit$id")
|
||||||
|
data class Net2Ex(val id: Int) : Route("net2ex$id")
|
||||||
|
data object Net3Map : Route("net3map")
|
||||||
|
data class Net3Lesson(val id: Int) : Route("net3unit$id")
|
||||||
|
data class Net3Ex(val id: Int) : Route("net3ex$id")
|
||||||
|
data object Net4Map : Route("net4map")
|
||||||
|
data class Net4Lesson(val id: Int) : Route("net4unit$id")
|
||||||
|
data class Net4Ex(val id: Int) : Route("net4ex$id")
|
||||||
|
data object HwYears : Route("hwyears")
|
||||||
|
data object HwMap : Route("hwmap")
|
||||||
|
data class HwLesson(val id: Int) : Route("hwunit$id")
|
||||||
|
data class HwEx(val id: Int) : Route("hwex$id")
|
||||||
|
data object Hw2Map : Route("hw2map")
|
||||||
|
data class Hw2Lesson(val id: Int) : Route("hw2unit$id")
|
||||||
|
data class Hw2Ex(val id: Int) : Route("hw2ex$id")
|
||||||
|
data object Hw3Map : Route("hw3map")
|
||||||
|
data class Hw3Lesson(val id: Int) : Route("hw3unit$id")
|
||||||
|
data class Hw3Ex(val id: Int) : Route("hw3ex$id")
|
||||||
|
data object Hw4Map : Route("hw4map")
|
||||||
|
data class Hw4Lesson(val id: Int) : Route("hw4unit$id")
|
||||||
|
data class Hw4Ex(val id: Int) : Route("hw4ex$id")
|
||||||
|
data object OnYears : Route("onyears")
|
||||||
|
data object OnMap : Route("onmap")
|
||||||
|
data class OnLesson(val id: Int) : Route("onunit$id")
|
||||||
|
data class OnEx(val id: Int) : Route("onex$id")
|
||||||
|
data object On2Map : Route("on2map")
|
||||||
|
data class On2Lesson(val id: Int) : Route("on2unit$id")
|
||||||
|
data class On2Ex(val id: Int) : Route("on2ex$id")
|
||||||
|
data object On3Map : Route("on3map")
|
||||||
|
data class On3Lesson(val id: Int) : Route("on3unit$id")
|
||||||
|
data class On3Ex(val id: Int) : Route("on3ex$id")
|
||||||
|
data object On4Map : Route("on4map")
|
||||||
|
data class On4Lesson(val id: Int) : Route("on4unit$id")
|
||||||
|
data class On4Ex(val id: Int) : Route("on4ex$id")
|
||||||
|
data object EnYears : Route("enyears")
|
||||||
|
data class EnMap(val year: Int) : Route("en${year}map")
|
||||||
|
data class EnLesson(val year: Int, val id: Int) : Route("en${year}unit$id")
|
||||||
|
data class EnEx(val year: Int, val id: Int) : Route("en${year}ex$id")
|
||||||
|
data object LitYears : Route("lityears")
|
||||||
|
data object LitMap : Route("litmap")
|
||||||
|
data class LitLesson(val id: Int) : Route("litunit$id")
|
||||||
|
data class LitEx(val id: Int) : Route("litex$id")
|
||||||
|
data object Lit2Map : Route("lit2map")
|
||||||
|
data class Lit2Lesson(val id: Int) : Route("lit2unit$id")
|
||||||
|
data class Lit2Ex(val id: Int) : Route("lit2ex$id")
|
||||||
|
data object Lit3Map : Route("lit3map")
|
||||||
|
data class Lit3Lesson(val id: Int) : Route("lit3unit$id")
|
||||||
|
data class Lit3Ex(val id: Int) : Route("lit3ex$id")
|
||||||
|
data object Lit4Map : Route("lit4map")
|
||||||
|
data class Lit4Lesson(val id: Int) : Route("lit4unit$id")
|
||||||
|
data class Lit4Ex(val id: Int) : Route("lit4ex$id")
|
||||||
|
data object SciMap : Route("scimap")
|
||||||
|
data object ChemMap : Route("chemmap")
|
||||||
|
data class ChemLesson(val id: Int) : Route("chemunit$id")
|
||||||
|
data class ChemEx(val id: Int) : Route("chemex$id")
|
||||||
|
data object BioMap : Route("biomap")
|
||||||
|
data class BioLesson(val id: Int) : Route("biounit$id")
|
||||||
|
data class BioEx(val id: Int) : Route("bioex$id")
|
||||||
|
data object MatYears : Route("matyears")
|
||||||
|
data object Mat0Map : Route("mat0map")
|
||||||
|
data class Mat0Lesson(val id: Int) : Route("mat0unit$id")
|
||||||
|
data class Mat0Ex(val id: Int) : Route("mat0ex$id")
|
||||||
|
data object MatMap : Route("matmap")
|
||||||
|
data class MatLesson(val id: Int) : Route("matunit$id")
|
||||||
|
data class MatEx(val id: Int) : Route("matex$id")
|
||||||
|
data object Mat2Map : Route("mat2map")
|
||||||
|
data class Mat2Lesson(val id: Int) : Route("mat2unit$id")
|
||||||
|
data class Mat2Ex(val id: Int) : Route("mat2ex$id")
|
||||||
|
data object Mat3Map : Route("mat3map")
|
||||||
|
data class Mat3Lesson(val id: Int) : Route("mat3unit$id")
|
||||||
|
data class Mat3Ex(val id: Int) : Route("mat3ex$id")
|
||||||
|
data object Mat4Map : Route("mat4map")
|
||||||
|
data class Mat4Lesson(val id: Int) : Route("mat4unit$id")
|
||||||
|
data class Mat4Ex(val id: Int) : Route("mat4ex$id")
|
||||||
|
data object FyzYears : Route("fyzyears")
|
||||||
|
data object FyzMap : Route("fyzmap")
|
||||||
|
data class FyzLesson(val id: Int) : Route("fyzunit$id")
|
||||||
|
data class FyzEx(val id: Int) : Route("fyzex$id")
|
||||||
|
data object Fyz2Map : Route("fyz2map")
|
||||||
|
data class Fyz2Lesson(val id: Int) : Route("fyz2unit$id")
|
||||||
|
data class Fyz2Ex(val id: Int) : Route("fyz2ex$id")
|
||||||
|
data object Fyz3Map : Route("fyz3map")
|
||||||
|
data class Fyz3Lesson(val id: Int) : Route("fyz3unit$id")
|
||||||
|
data class Fyz3Ex(val id: Int) : Route("fyz3ex$id")
|
||||||
|
data object Fyz4Map : Route("fyz4map")
|
||||||
|
data class Fyz4Lesson(val id: Int) : Route("fyz4unit$id")
|
||||||
|
data class Fyz4Ex(val id: Int) : Route("fyz4ex$id")
|
||||||
|
data object TgYears : Route("tgyears")
|
||||||
|
data object TgMap : Route("tgmap")
|
||||||
|
data class TgLesson(val id: Int) : Route("tgunit$id")
|
||||||
|
data class TgEx(val id: Int) : Route("tgex$id")
|
||||||
|
data object Tg2Map : Route("tg2map")
|
||||||
|
data class Tg2Lesson(val id: Int) : Route("tg2unit$id")
|
||||||
|
data class Tg2Ex(val id: Int) : Route("tg2ex$id")
|
||||||
|
data object Tg3Map : Route("tg3map")
|
||||||
|
data class Tg3Lesson(val id: Int) : Route("tg3unit$id")
|
||||||
|
data class Tg3Ex(val id: Int) : Route("tg3ex$id")
|
||||||
|
data object Tg4Map : Route("tg4map")
|
||||||
|
data class Tg4Lesson(val id: Int) : Route("tg4unit$id")
|
||||||
|
data class Tg4Ex(val id: Int) : Route("tg4ex$id")
|
||||||
|
data object PgYears : Route("pgyears")
|
||||||
|
data object PgMap : Route("pgmap")
|
||||||
|
data class PgLesson(val id: Int) : Route("pgunit$id")
|
||||||
|
data class PgEx(val id: Int) : Route("pgex$id")
|
||||||
|
data object Pg2Map : Route("pg2map")
|
||||||
|
data class Pg2Lesson(val id: Int) : Route("pg2unit$id")
|
||||||
|
data class Pg2Ex(val id: Int) : Route("pg2ex$id")
|
||||||
|
data object Pg3Map : Route("pg3map")
|
||||||
|
data class Pg3Lesson(val id: Int) : Route("pg3unit$id")
|
||||||
|
data class Pg3Ex(val id: Int) : Route("pg3ex$id")
|
||||||
|
data object Pg4Map : Route("pg4map")
|
||||||
|
data class Pg4Lesson(val id: Int) : Route("pg4unit$id")
|
||||||
|
data class Pg4Ex(val id: Int) : Route("pg4ex$id")
|
||||||
|
data object SwYears : Route("swyears")
|
||||||
|
data object SwMap : Route("swmap")
|
||||||
|
data class SwLesson(val id: Int) : Route("swunit$id")
|
||||||
|
data class SwEx(val id: Int) : Route("swex$id")
|
||||||
|
data object Sw2Map : Route("sw2map")
|
||||||
|
data class Sw2Lesson(val id: Int) : Route("sw2unit$id")
|
||||||
|
data class Sw2Ex(val id: Int) : Route("sw2ex$id")
|
||||||
|
data object Sw3Map : Route("sw3map")
|
||||||
|
data class Sw3Lesson(val id: Int) : Route("sw3unit$id")
|
||||||
|
data class Sw3Ex(val id: Int) : Route("sw3ex$id")
|
||||||
|
data object Sw4Map : Route("sw4map")
|
||||||
|
data class Sw4Lesson(val id: Int) : Route("sw4unit$id")
|
||||||
|
data class Sw4Ex(val id: Int) : Route("sw4ex$id")
|
||||||
|
data object CsYears : Route("csyears")
|
||||||
|
data object CsMap : Route("csmap")
|
||||||
|
data class CsLesson(val id: Int) : Route("csunit$id")
|
||||||
|
data object Cs2Map : Route("cs2map")
|
||||||
|
data class Cs2Lesson(val id: Int) : Route("cs2unit$id")
|
||||||
|
data object Cs3Map : Route("cs3map")
|
||||||
|
data class Cs3Lesson(val id: Int) : Route("cs3unit$id")
|
||||||
|
data object Cs4Map : Route("cs4map")
|
||||||
|
data class Cs4Lesson(val id: Int) : Route("cs4unit$id")
|
||||||
|
data object CzechMap : Route("czechmap")
|
||||||
|
data object Mluvnice : Route("mluvnice")
|
||||||
|
data class MluvEx(val n: Int) : Route("mluve$n")
|
||||||
|
data object ReadingList : Route("readinglist")
|
||||||
|
data class Book(val id: String) : Route(if (id == "1984") "cetba1984" else "cetbaFuks")
|
||||||
|
data class BookQuiz(val id: String) : Route(if (id == "1984") "cetba1984quiz" else "cetbaFuksQuiz")
|
||||||
|
data class BookPlot(val id: String) : Route(if (id == "1984") "cetba1984dej" else "cetbaFuksDej")
|
||||||
|
|
||||||
|
/** Bottom-navigation roots; everything else is pushed on top of [Subjects]. */
|
||||||
|
val isRoot: Boolean get() = this is Welcome || this is Subjects || this is Stats || this is Settings
|
||||||
|
|
||||||
|
/** Screens with their own bottom action bar; the navigation bar stays hidden there. */
|
||||||
|
val isExercise: Boolean
|
||||||
|
get() = this is GermanEx || this is Vocab || this is NetEx || this is HwEx ||
|
||||||
|
this is OnEx || this is On2Ex || this is On3Ex || this is On4Ex || this is EnLesson || this is EnEx || this is DeLesson || this is DeEx || this is MluvEx || this is BookQuiz || this is BookPlot ||
|
||||||
|
this is Net2Lesson || this is Net2Ex || this is Net3Lesson || this is Net3Ex || this is Net4Lesson || this is Net4Ex ||
|
||||||
|
this is Hw2Lesson || this is Hw2Ex || this is Hw3Lesson || this is Hw3Ex || this is Hw4Lesson || this is Hw4Ex ||
|
||||||
|
this is NetLesson || this is HwLesson || this is OnLesson || this is On2Lesson || this is On3Lesson || this is On4Lesson ||
|
||||||
|
this is LitLesson || this is LitEx || this is Lit2Lesson || this is Lit2Ex ||
|
||||||
|
this is Lit3Lesson || this is Lit3Ex || this is Lit4Lesson || this is Lit4Ex ||
|
||||||
|
this is ChemLesson || this is ChemEx || this is BioLesson || this is BioEx ||
|
||||||
|
this is FyzLesson || this is FyzEx || this is Fyz2Lesson || this is Fyz2Ex ||
|
||||||
|
this is Fyz3Lesson || this is Fyz3Ex || this is Fyz4Lesson || this is Fyz4Ex ||
|
||||||
|
this is Mat0Lesson || this is Mat0Ex || this is MatLesson || this is MatEx ||
|
||||||
|
this is Mat2Lesson || this is Mat2Ex || this is Mat3Lesson || this is Mat3Ex ||
|
||||||
|
this is Mat4Lesson || this is Mat4Ex
|
||||||
|
|
||||||
|
/** Full stack from the practice root to this screen, so back always goes up one level. */
|
||||||
|
fun stack(): List<Route> = when (this) {
|
||||||
|
Welcome, Subjects -> listOf(Subjects)
|
||||||
|
Stats -> listOf(Stats)
|
||||||
|
Settings -> listOf(Settings)
|
||||||
|
DeHome -> listOf(Subjects, DeHome)
|
||||||
|
is DeMap -> listOf(Subjects, DeHome, this)
|
||||||
|
is DeLesson -> listOf(Subjects, DeHome, DeMap(year), this)
|
||||||
|
is DeEx -> listOf(Subjects, DeHome, DeMap(year), DeLesson(year, id), this)
|
||||||
|
Roadmap -> listOf(Subjects, DeHome, Roadmap)
|
||||||
|
is UnitMap -> listOf(Subjects, DeHome, Roadmap, this)
|
||||||
|
is GermanEx -> listOf(Subjects, DeHome, Roadmap, UnitMap(unitId), this)
|
||||||
|
is Vocab -> listOf(Subjects, DeHome, Roadmap, UnitMap(unitId), this)
|
||||||
|
NetYears -> listOf(Subjects, NetYears)
|
||||||
|
NetMap -> listOf(Subjects, NetYears, NetMap)
|
||||||
|
is NetLesson -> listOf(Subjects, NetYears, NetMap, this)
|
||||||
|
is NetEx -> listOf(Subjects, NetYears, NetMap, NetLesson(id), this)
|
||||||
|
Net2Map -> listOf(Subjects, NetYears, Net2Map)
|
||||||
|
is Net2Lesson -> listOf(Subjects, NetYears, Net2Map, this)
|
||||||
|
is Net2Ex -> listOf(Subjects, NetYears, Net2Map, Net2Lesson(id), this)
|
||||||
|
Net3Map -> listOf(Subjects, NetYears, Net3Map)
|
||||||
|
is Net3Lesson -> listOf(Subjects, NetYears, Net3Map, this)
|
||||||
|
is Net3Ex -> listOf(Subjects, NetYears, Net3Map, Net3Lesson(id), this)
|
||||||
|
Net4Map -> listOf(Subjects, NetYears, Net4Map)
|
||||||
|
is Net4Lesson -> listOf(Subjects, NetYears, Net4Map, this)
|
||||||
|
is Net4Ex -> listOf(Subjects, NetYears, Net4Map, Net4Lesson(id), this)
|
||||||
|
HwYears -> listOf(Subjects, HwYears)
|
||||||
|
HwMap -> listOf(Subjects, HwYears, HwMap)
|
||||||
|
is HwLesson -> listOf(Subjects, HwYears, HwMap, this)
|
||||||
|
is HwEx -> listOf(Subjects, HwYears, HwMap, HwLesson(id), this)
|
||||||
|
Hw2Map -> listOf(Subjects, HwYears, Hw2Map)
|
||||||
|
is Hw2Lesson -> listOf(Subjects, HwYears, Hw2Map, this)
|
||||||
|
is Hw2Ex -> listOf(Subjects, HwYears, Hw2Map, Hw2Lesson(id), this)
|
||||||
|
Hw3Map -> listOf(Subjects, HwYears, Hw3Map)
|
||||||
|
is Hw3Lesson -> listOf(Subjects, HwYears, Hw3Map, this)
|
||||||
|
is Hw3Ex -> listOf(Subjects, HwYears, Hw3Map, Hw3Lesson(id), this)
|
||||||
|
Hw4Map -> listOf(Subjects, HwYears, Hw4Map)
|
||||||
|
is Hw4Lesson -> listOf(Subjects, HwYears, Hw4Map, this)
|
||||||
|
is Hw4Ex -> listOf(Subjects, HwYears, Hw4Map, Hw4Lesson(id), this)
|
||||||
|
OnYears -> listOf(Subjects, OnYears)
|
||||||
|
OnMap -> listOf(Subjects, OnYears, OnMap)
|
||||||
|
is OnLesson -> listOf(Subjects, OnYears, OnMap, this)
|
||||||
|
is OnEx -> listOf(Subjects, OnYears, OnMap, OnLesson(id), this)
|
||||||
|
On2Map -> listOf(Subjects, OnYears, On2Map)
|
||||||
|
is On2Lesson -> listOf(Subjects, OnYears, On2Map, this)
|
||||||
|
is On2Ex -> listOf(Subjects, OnYears, On2Map, On2Lesson(id), this)
|
||||||
|
On3Map -> listOf(Subjects, OnYears, On3Map)
|
||||||
|
is On3Lesson -> listOf(Subjects, OnYears, On3Map, this)
|
||||||
|
is On3Ex -> listOf(Subjects, OnYears, On3Map, On3Lesson(id), this)
|
||||||
|
On4Map -> listOf(Subjects, OnYears, On4Map)
|
||||||
|
is On4Lesson -> listOf(Subjects, OnYears, On4Map, this)
|
||||||
|
is On4Ex -> listOf(Subjects, OnYears, On4Map, On4Lesson(id), this)
|
||||||
|
EnYears -> listOf(Subjects, EnYears)
|
||||||
|
is EnMap -> listOf(Subjects, EnYears, this)
|
||||||
|
is EnLesson -> listOf(Subjects, EnYears, EnMap(year), this)
|
||||||
|
is EnEx -> listOf(Subjects, EnYears, EnMap(year), EnLesson(year, id), this)
|
||||||
|
LitYears -> listOf(Subjects, CzechMap, LitYears)
|
||||||
|
LitMap -> listOf(Subjects, CzechMap, LitYears, LitMap)
|
||||||
|
is LitLesson -> listOf(Subjects, CzechMap, LitYears, LitMap, this)
|
||||||
|
is LitEx -> listOf(Subjects, CzechMap, LitYears, LitMap, LitLesson(id), this)
|
||||||
|
Lit2Map -> listOf(Subjects, CzechMap, LitYears, Lit2Map)
|
||||||
|
is Lit2Lesson -> listOf(Subjects, CzechMap, LitYears, Lit2Map, this)
|
||||||
|
is Lit2Ex -> listOf(Subjects, CzechMap, LitYears, Lit2Map, Lit2Lesson(id), this)
|
||||||
|
Lit3Map -> listOf(Subjects, CzechMap, LitYears, Lit3Map)
|
||||||
|
is Lit3Lesson -> listOf(Subjects, CzechMap, LitYears, Lit3Map, this)
|
||||||
|
is Lit3Ex -> listOf(Subjects, CzechMap, LitYears, Lit3Map, Lit3Lesson(id), this)
|
||||||
|
Lit4Map -> listOf(Subjects, CzechMap, LitYears, Lit4Map)
|
||||||
|
is Lit4Lesson -> listOf(Subjects, CzechMap, LitYears, Lit4Map, this)
|
||||||
|
is Lit4Ex -> listOf(Subjects, CzechMap, LitYears, Lit4Map, Lit4Lesson(id), this)
|
||||||
|
SciMap -> listOf(Subjects, SciMap)
|
||||||
|
ChemMap -> listOf(Subjects, SciMap, ChemMap)
|
||||||
|
is ChemLesson -> listOf(Subjects, SciMap, ChemMap, this)
|
||||||
|
is ChemEx -> listOf(Subjects, SciMap, ChemMap, ChemLesson(id), this)
|
||||||
|
BioMap -> listOf(Subjects, SciMap, BioMap)
|
||||||
|
is BioLesson -> listOf(Subjects, SciMap, BioMap, this)
|
||||||
|
is BioEx -> listOf(Subjects, SciMap, BioMap, BioLesson(id), this)
|
||||||
|
MatYears -> listOf(Subjects, MatYears)
|
||||||
|
Mat0Map -> listOf(Subjects, MatYears, Mat0Map)
|
||||||
|
is Mat0Lesson -> listOf(Subjects, MatYears, Mat0Map, this)
|
||||||
|
is Mat0Ex -> listOf(Subjects, MatYears, Mat0Map, Mat0Lesson(id), this)
|
||||||
|
MatMap -> listOf(Subjects, MatYears, MatMap)
|
||||||
|
is MatLesson -> listOf(Subjects, MatYears, MatMap, this)
|
||||||
|
is MatEx -> listOf(Subjects, MatYears, MatMap, MatLesson(id), this)
|
||||||
|
Mat2Map -> listOf(Subjects, MatYears, Mat2Map)
|
||||||
|
is Mat2Lesson -> listOf(Subjects, MatYears, Mat2Map, this)
|
||||||
|
is Mat2Ex -> listOf(Subjects, MatYears, Mat2Map, Mat2Lesson(id), this)
|
||||||
|
Mat3Map -> listOf(Subjects, MatYears, Mat3Map)
|
||||||
|
is Mat3Lesson -> listOf(Subjects, MatYears, Mat3Map, this)
|
||||||
|
is Mat3Ex -> listOf(Subjects, MatYears, Mat3Map, Mat3Lesson(id), this)
|
||||||
|
Mat4Map -> listOf(Subjects, MatYears, Mat4Map)
|
||||||
|
is Mat4Lesson -> listOf(Subjects, MatYears, Mat4Map, this)
|
||||||
|
is Mat4Ex -> listOf(Subjects, MatYears, Mat4Map, Mat4Lesson(id), this)
|
||||||
|
FyzYears -> listOf(Subjects, FyzYears)
|
||||||
|
FyzMap -> listOf(Subjects, FyzYears, FyzMap)
|
||||||
|
is FyzLesson -> listOf(Subjects, FyzYears, FyzMap, this)
|
||||||
|
is FyzEx -> listOf(Subjects, FyzYears, FyzMap, FyzLesson(id), this)
|
||||||
|
Fyz2Map -> listOf(Subjects, FyzYears, Fyz2Map)
|
||||||
|
is Fyz2Lesson -> listOf(Subjects, FyzYears, Fyz2Map, this)
|
||||||
|
is Fyz2Ex -> listOf(Subjects, FyzYears, Fyz2Map, Fyz2Lesson(id), this)
|
||||||
|
Fyz3Map -> listOf(Subjects, FyzYears, Fyz3Map)
|
||||||
|
is Fyz3Lesson -> listOf(Subjects, FyzYears, Fyz3Map, this)
|
||||||
|
is Fyz3Ex -> listOf(Subjects, FyzYears, Fyz3Map, Fyz3Lesson(id), this)
|
||||||
|
Fyz4Map -> listOf(Subjects, FyzYears, Fyz4Map)
|
||||||
|
is Fyz4Lesson -> listOf(Subjects, FyzYears, Fyz4Map, this)
|
||||||
|
is Fyz4Ex -> listOf(Subjects, FyzYears, Fyz4Map, Fyz4Lesson(id), this)
|
||||||
|
TgYears -> listOf(Subjects, TgYears)
|
||||||
|
TgMap -> listOf(Subjects, TgYears, TgMap)
|
||||||
|
is TgLesson -> listOf(Subjects, TgYears, TgMap, this)
|
||||||
|
is TgEx -> listOf(Subjects, TgYears, TgMap, TgLesson(id), this)
|
||||||
|
Tg2Map -> listOf(Subjects, TgYears, Tg2Map)
|
||||||
|
is Tg2Lesson -> listOf(Subjects, TgYears, Tg2Map, this)
|
||||||
|
is Tg2Ex -> listOf(Subjects, TgYears, Tg2Map, Tg2Lesson(id), this)
|
||||||
|
Tg3Map -> listOf(Subjects, TgYears, Tg3Map)
|
||||||
|
is Tg3Lesson -> listOf(Subjects, TgYears, Tg3Map, this)
|
||||||
|
is Tg3Ex -> listOf(Subjects, TgYears, Tg3Map, Tg3Lesson(id), this)
|
||||||
|
Tg4Map -> listOf(Subjects, TgYears, Tg4Map)
|
||||||
|
is Tg4Lesson -> listOf(Subjects, TgYears, Tg4Map, this)
|
||||||
|
is Tg4Ex -> listOf(Subjects, TgYears, Tg4Map, Tg4Lesson(id), this)
|
||||||
|
PgYears -> listOf(Subjects, PgYears)
|
||||||
|
PgMap -> listOf(Subjects, PgYears, PgMap)
|
||||||
|
is PgLesson -> listOf(Subjects, PgYears, PgMap, this)
|
||||||
|
is PgEx -> listOf(Subjects, PgYears, PgMap, PgLesson(id), this)
|
||||||
|
Pg2Map -> listOf(Subjects, PgYears, Pg2Map)
|
||||||
|
is Pg2Lesson -> listOf(Subjects, PgYears, Pg2Map, this)
|
||||||
|
is Pg2Ex -> listOf(Subjects, PgYears, Pg2Map, Pg2Lesson(id), this)
|
||||||
|
Pg3Map -> listOf(Subjects, PgYears, Pg3Map)
|
||||||
|
is Pg3Lesson -> listOf(Subjects, PgYears, Pg3Map, this)
|
||||||
|
is Pg3Ex -> listOf(Subjects, PgYears, Pg3Map, Pg3Lesson(id), this)
|
||||||
|
Pg4Map -> listOf(Subjects, PgYears, Pg4Map)
|
||||||
|
is Pg4Lesson -> listOf(Subjects, PgYears, Pg4Map, this)
|
||||||
|
is Pg4Ex -> listOf(Subjects, PgYears, Pg4Map, Pg4Lesson(id), this)
|
||||||
|
SwYears -> listOf(Subjects, SwYears)
|
||||||
|
SwMap -> listOf(Subjects, SwYears, SwMap)
|
||||||
|
is SwLesson -> listOf(Subjects, SwYears, SwMap, this)
|
||||||
|
is SwEx -> listOf(Subjects, SwYears, SwMap, SwLesson(id), this)
|
||||||
|
Sw2Map -> listOf(Subjects, SwYears, Sw2Map)
|
||||||
|
is Sw2Lesson -> listOf(Subjects, SwYears, Sw2Map, this)
|
||||||
|
is Sw2Ex -> listOf(Subjects, SwYears, Sw2Map, Sw2Lesson(id), this)
|
||||||
|
Sw3Map -> listOf(Subjects, SwYears, Sw3Map)
|
||||||
|
is Sw3Lesson -> listOf(Subjects, SwYears, Sw3Map, this)
|
||||||
|
is Sw3Ex -> listOf(Subjects, SwYears, Sw3Map, Sw3Lesson(id), this)
|
||||||
|
Sw4Map -> listOf(Subjects, SwYears, Sw4Map)
|
||||||
|
is Sw4Lesson -> listOf(Subjects, SwYears, Sw4Map, this)
|
||||||
|
is Sw4Ex -> listOf(Subjects, SwYears, Sw4Map, Sw4Lesson(id), this)
|
||||||
|
CsYears -> listOf(Subjects, CsYears)
|
||||||
|
CsMap -> listOf(Subjects, CsYears, CsMap)
|
||||||
|
is CsLesson -> listOf(Subjects, CsYears, CsMap, this)
|
||||||
|
Cs2Map -> listOf(Subjects, CsYears, Cs2Map)
|
||||||
|
is Cs2Lesson -> listOf(Subjects, CsYears, Cs2Map, this)
|
||||||
|
Cs3Map -> listOf(Subjects, CsYears, Cs3Map)
|
||||||
|
is Cs3Lesson -> listOf(Subjects, CsYears, Cs3Map, this)
|
||||||
|
Cs4Map -> listOf(Subjects, CsYears, Cs4Map)
|
||||||
|
is Cs4Lesson -> listOf(Subjects, CsYears, Cs4Map, this)
|
||||||
|
CzechMap -> listOf(Subjects, CzechMap)
|
||||||
|
Mluvnice -> listOf(Subjects, CzechMap, Mluvnice)
|
||||||
|
is MluvEx -> listOf(Subjects, CzechMap, Mluvnice, this)
|
||||||
|
ReadingList -> listOf(Subjects, CzechMap, ReadingList)
|
||||||
|
is Book -> listOf(Subjects, CzechMap, ReadingList, this)
|
||||||
|
is BookQuiz -> listOf(Subjects, CzechMap, ReadingList, Book(id), this)
|
||||||
|
is BookPlot -> listOf(Subjects, CzechMap, ReadingList, Book(id), this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun routeFromPage(page: String): Route? = when (page) {
|
||||||
|
"welcome" -> Route.Welcome
|
||||||
|
"subjects" -> Route.Subjects
|
||||||
|
"stats" -> Route.Stats
|
||||||
|
"settings" -> Route.Settings
|
||||||
|
"roadmap" -> Route.Roadmap
|
||||||
|
"dehome" -> Route.DeHome
|
||||||
|
"netyears" -> Route.NetYears
|
||||||
|
"netmap" -> Route.NetMap
|
||||||
|
"net2map" -> Route.Net2Map
|
||||||
|
"net3map" -> Route.Net3Map
|
||||||
|
"net4map" -> Route.Net4Map
|
||||||
|
"hw2map" -> Route.Hw2Map
|
||||||
|
"hw3map" -> Route.Hw3Map
|
||||||
|
"hw4map" -> Route.Hw4Map
|
||||||
|
"hwyears" -> Route.HwYears
|
||||||
|
"hwmap" -> Route.HwMap
|
||||||
|
"onyears" -> Route.OnYears
|
||||||
|
"onmap" -> Route.OnMap
|
||||||
|
"on2map" -> Route.On2Map
|
||||||
|
"on3map" -> Route.On3Map
|
||||||
|
"on4map" -> Route.On4Map
|
||||||
|
"enyears" -> Route.EnYears
|
||||||
|
"lityears" -> Route.LitYears
|
||||||
|
"litmap" -> Route.LitMap
|
||||||
|
"lit2map" -> Route.Lit2Map
|
||||||
|
"lit3map" -> Route.Lit3Map
|
||||||
|
"lit4map" -> Route.Lit4Map
|
||||||
|
"scimap" -> Route.SciMap
|
||||||
|
"chemmap" -> Route.ChemMap
|
||||||
|
"biomap" -> Route.BioMap
|
||||||
|
"matyears" -> Route.MatYears
|
||||||
|
"mat0map" -> Route.Mat0Map
|
||||||
|
"matmap" -> Route.MatMap
|
||||||
|
"mat2map" -> Route.Mat2Map
|
||||||
|
"mat3map" -> Route.Mat3Map
|
||||||
|
"mat4map" -> Route.Mat4Map
|
||||||
|
"fyzyears" -> Route.FyzYears
|
||||||
|
"fyzmap" -> Route.FyzMap
|
||||||
|
"fyz2map" -> Route.Fyz2Map
|
||||||
|
"fyz3map" -> Route.Fyz3Map
|
||||||
|
"fyz4map" -> Route.Fyz4Map
|
||||||
|
"tgyears" -> Route.TgYears
|
||||||
|
"tgmap" -> Route.TgMap
|
||||||
|
"tg2map" -> Route.Tg2Map
|
||||||
|
"tg3map" -> Route.Tg3Map
|
||||||
|
"tg4map" -> Route.Tg4Map
|
||||||
|
"pgyears" -> Route.PgYears
|
||||||
|
"pgmap" -> Route.PgMap
|
||||||
|
"pg2map" -> Route.Pg2Map
|
||||||
|
"pg3map" -> Route.Pg3Map
|
||||||
|
"pg4map" -> Route.Pg4Map
|
||||||
|
"swyears" -> Route.SwYears
|
||||||
|
"swmap" -> Route.SwMap
|
||||||
|
"sw2map" -> Route.Sw2Map
|
||||||
|
"sw3map" -> Route.Sw3Map
|
||||||
|
"sw4map" -> Route.Sw4Map
|
||||||
|
"csyears" -> Route.CsYears
|
||||||
|
"csmap" -> Route.CsMap
|
||||||
|
"cs2map" -> Route.Cs2Map
|
||||||
|
"cs3map" -> Route.Cs3Map
|
||||||
|
"cs4map" -> Route.Cs4Map
|
||||||
|
"czechmap" -> Route.CzechMap
|
||||||
|
"mluvnice" -> Route.Mluvnice
|
||||||
|
"readinglist" -> Route.ReadingList
|
||||||
|
"cetba1984" -> Route.Book("1984")
|
||||||
|
"cetba1984quiz" -> Route.BookQuiz("1984")
|
||||||
|
"cetba1984dej" -> Route.BookPlot("1984")
|
||||||
|
"cetbaFuks" -> Route.Book("fuks")
|
||||||
|
"cetbaFuksQuiz" -> Route.BookQuiz("fuks")
|
||||||
|
"cetbaFuksDej" -> Route.BookPlot("fuks")
|
||||||
|
else -> {
|
||||||
|
Regex("""unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.UnitMap(it.groupValues[1].toInt() - 1)
|
||||||
|
}
|
||||||
|
Regex("""u(\d+)e(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.GermanEx(it.groupValues[1].toInt() - 1, it.groupValues[2].toInt())
|
||||||
|
}
|
||||||
|
Regex("""u(\d+)vocab""").matchEntire(page)?.let {
|
||||||
|
return Route.Vocab(it.groupValues[1].toInt() - 1)
|
||||||
|
}
|
||||||
|
Regex("""netunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.NetLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""netex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.NetEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""hwunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.HwLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""hwex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.HwEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""chemunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.ChemLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""chemex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.ChemEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""biounit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.BioLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""bioex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.BioEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""mat0unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Mat0Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""mat0ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Mat0Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""mat4unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Mat4Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""mat4ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Mat4Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""mat3unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Mat3Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""mat3ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Mat3Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""mat2unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Mat2Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""mat2ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Mat2Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""matunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.MatLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""matex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.MatEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""fyz4unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Fyz4Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""fyz4ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Fyz4Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""fyz3unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Fyz3Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""fyz3ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Fyz3Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""fyz2unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Fyz2Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""fyz2ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Fyz2Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""fyzunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.FyzLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""fyzex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.FyzEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""tg4unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Tg4Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""tg4ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Tg4Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""tg3unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Tg3Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""tg3ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Tg3Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""tg2unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Tg2Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""tg2ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Tg2Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""tgunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.TgLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""tgex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.TgEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""pg4unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Pg4Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""pg4ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Pg4Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""pg3unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Pg3Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""pg3ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Pg3Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""pg2unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Pg2Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""pg2ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Pg2Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""pgunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.PgLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""pgex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.PgEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""sw4unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Sw4Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""sw4ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Sw4Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""sw3unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Sw3Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""sw3ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Sw3Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""sw2unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Sw2Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""sw2ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Sw2Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""swunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.SwLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""swex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.SwEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""cs4unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Cs4Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""cs3unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Cs3Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""cs2unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Cs2Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""csunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.CsLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""lit4unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Lit4Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""lit4ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Lit4Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""lit3unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Lit3Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""lit3ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Lit3Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""lit2unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Lit2Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""lit2ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.Lit2Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""litunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.LitLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""litex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.LitEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""de(\d)map""").matchEntire(page)?.let {
|
||||||
|
return Route.DeMap(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""de(\d)unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.DeLesson(it.groupValues[1].toInt(), it.groupValues[2].toInt())
|
||||||
|
}
|
||||||
|
Regex("""de(\d)ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.DeEx(it.groupValues[1].toInt(), it.groupValues[2].toInt())
|
||||||
|
}
|
||||||
|
Regex("""net([234])unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
val year = it.groupValues[1].toInt()
|
||||||
|
val id = it.groupValues[2].toInt()
|
||||||
|
return when (year) {
|
||||||
|
3 -> Route.Net3Lesson(id)
|
||||||
|
4 -> Route.Net4Lesson(id)
|
||||||
|
else -> Route.Net2Lesson(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Regex("""net([234])ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
val year = it.groupValues[1].toInt()
|
||||||
|
val id = it.groupValues[2].toInt()
|
||||||
|
return when (year) {
|
||||||
|
3 -> Route.Net3Ex(id)
|
||||||
|
4 -> Route.Net4Ex(id)
|
||||||
|
else -> Route.Net2Ex(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Regex("""hw([234])unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
val year = it.groupValues[1].toInt()
|
||||||
|
val id = it.groupValues[2].toInt()
|
||||||
|
return when (year) {
|
||||||
|
3 -> Route.Hw3Lesson(id)
|
||||||
|
4 -> Route.Hw4Lesson(id)
|
||||||
|
else -> Route.Hw2Lesson(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Regex("""hw([234])ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
val year = it.groupValues[1].toInt()
|
||||||
|
val id = it.groupValues[2].toInt()
|
||||||
|
return when (year) {
|
||||||
|
3 -> Route.Hw3Ex(id)
|
||||||
|
4 -> Route.Hw4Ex(id)
|
||||||
|
else -> Route.Hw2Ex(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Regex("""en(\d)map""").matchEntire(page)?.let {
|
||||||
|
return Route.EnMap(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""en(\d)unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.EnLesson(it.groupValues[1].toInt(), it.groupValues[2].toInt())
|
||||||
|
}
|
||||||
|
Regex("""en(\d)ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.EnEx(it.groupValues[1].toInt(), it.groupValues[2].toInt())
|
||||||
|
}
|
||||||
|
Regex("""on4unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.On4Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""on4ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.On4Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""on3unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.On3Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""on3ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.On3Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""on2unit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.On2Lesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""on2ex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.On2Ex(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""onunit(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.OnLesson(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""onex(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.OnEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
Regex("""mluve(\d+)""").matchEntire(page)?.let {
|
||||||
|
return Route.MluvEx(it.groupValues[1].toInt())
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
718
android/shared/src/org/maturita/maturita/data/Answers.kt
Normal file
|
|
@ -0,0 +1,718 @@
|
||||||
|
package org.maturita.maturita.data
|
||||||
|
|
||||||
|
import java.text.Normalizer
|
||||||
|
|
||||||
|
fun normalizeAnswer(input: String?): String {
|
||||||
|
val src = (input ?: "").lowercase()
|
||||||
|
val pre = StringBuilder()
|
||||||
|
src.codePoints().forEach { ch ->
|
||||||
|
when (ch) {
|
||||||
|
0x00e4 -> pre.append("ae")
|
||||||
|
0x00f6 -> pre.append("oe")
|
||||||
|
0x00fc -> pre.append("ue")
|
||||||
|
0x00df -> pre.append("ss")
|
||||||
|
else -> pre.appendCodePoint(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val decomp = Normalizer.normalize(pre.toString(), Normalizer.Form.NFD)
|
||||||
|
val out = StringBuilder()
|
||||||
|
var prevSpace = false
|
||||||
|
decomp.codePoints().forEach { ch ->
|
||||||
|
val type = Character.getType(ch)
|
||||||
|
if (type == Character.NON_SPACING_MARK.toInt() ||
|
||||||
|
type == Character.COMBINING_SPACING_MARK.toInt() ||
|
||||||
|
type == Character.ENCLOSING_MARK.toInt()
|
||||||
|
) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (Character.isWhitespace(ch)) {
|
||||||
|
if (out.isNotEmpty() && !prevSpace) out.append(' ')
|
||||||
|
prevSpace = true
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (!Character.isLetterOrDigit(ch)) return@forEach
|
||||||
|
prevSpace = false
|
||||||
|
out.appendCodePoint(ch)
|
||||||
|
}
|
||||||
|
return out.toString().trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun answerAccepts(selNorm: String, ans: String?): Boolean {
|
||||||
|
if (ans.isNullOrEmpty()) return false
|
||||||
|
return ans.split('|').any { normalizeAnswer(it) == selNorm }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun answerIncomplete(selNorm: String, ans: String?): Boolean {
|
||||||
|
if (selNorm.isEmpty() || ans.isNullOrEmpty()) return false
|
||||||
|
for (part in ans.split('|')) {
|
||||||
|
val a = normalizeAnswer(part)
|
||||||
|
if (a.startsWith("$selNorm ") && a.length > selNorm.length + 1) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun netTxtEq(a: String?, b: String?): Boolean {
|
||||||
|
if (a == null || b == null) return a == b
|
||||||
|
val x = a.filterNot { it.isWhitespace() }.lowercase()
|
||||||
|
val y = b.filterNot { it.isWhitespace() }.lowercase()
|
||||||
|
return x == y
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasUmlaut(s: String?): Boolean {
|
||||||
|
if (s == null) return false
|
||||||
|
return s.any { it == 'ä' || it == 'ö' || it == 'ü' || it == 'ß' ||
|
||||||
|
it == 'Ä' || it == 'Ö' || it == 'Ü' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rules as src/math_check.c. Do not route numbers through normalizeAnswer.
|
||||||
|
|
||||||
|
private data class MFrac(val n: Long = 0, val d: Long = 1)
|
||||||
|
|
||||||
|
private class MTerm(
|
||||||
|
var ok: Boolean = true,
|
||||||
|
var a: MFrac = MFrac(),
|
||||||
|
var b: MFrac = MFrac(),
|
||||||
|
var rad: Long = 0,
|
||||||
|
var p: MFrac = MFrac(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun mGcd(a0: Long, b0: Long): Long {
|
||||||
|
var a = if (a0 < 0) -a0 else a0
|
||||||
|
var b = if (b0 < 0) -b0 else b0
|
||||||
|
while (b != 0L) {
|
||||||
|
val t = a % b
|
||||||
|
a = b
|
||||||
|
b = t
|
||||||
|
}
|
||||||
|
return if (a == 0L) 1 else a
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mMul(a: Long, b: Long): Long? = try {
|
||||||
|
Math.multiplyExact(a, b)
|
||||||
|
} catch (_: ArithmeticException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mAddL(a: Long, b: Long): Long? = try {
|
||||||
|
Math.addExact(a, b)
|
||||||
|
} catch (_: ArithmeticException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mSet(n0: Long, d0: Long): MFrac? {
|
||||||
|
if (d0 == 0L) return null
|
||||||
|
var n = n0
|
||||||
|
var d = d0
|
||||||
|
if (d < 0) {
|
||||||
|
n = -n
|
||||||
|
d = -d
|
||||||
|
}
|
||||||
|
val g = mGcd(n, d)
|
||||||
|
return MFrac(n / g, d / g)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mAddF(x: MFrac, y: MFrac): MFrac? {
|
||||||
|
val xn = mMul(x.n, y.d) ?: return null
|
||||||
|
val yn = mMul(y.n, x.d) ?: return null
|
||||||
|
val d = mMul(x.d, y.d) ?: return null
|
||||||
|
val s = mAddL(xn, yn) ?: return null
|
||||||
|
return mSet(s, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mMulF(x: MFrac, y: MFrac): MFrac? {
|
||||||
|
val n = mMul(x.n, y.n) ?: return null
|
||||||
|
val d = mMul(x.d, y.d) ?: return null
|
||||||
|
return mSet(n, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mDivF(x: MFrac, y: MFrac): MFrac? {
|
||||||
|
if (y.n == 0L) return null
|
||||||
|
return mMulF(x, MFrac(y.d, y.n))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mSameF(x: MFrac, y: MFrac) = x.n == y.n && x.d == y.d
|
||||||
|
|
||||||
|
private fun mPow(b: Long, e: Int): Long {
|
||||||
|
var r = 1L
|
||||||
|
var i = 0
|
||||||
|
while (i < e) {
|
||||||
|
r *= b
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mSqrtReduce(n0: Long): Pair<Long, Long>? {
|
||||||
|
if (n0 < 0 || n0 > 1_000_000) return null
|
||||||
|
var n = n0
|
||||||
|
var out = 1L
|
||||||
|
var f = 2L
|
||||||
|
while (f * f <= n) {
|
||||||
|
var c = 0
|
||||||
|
while (n % f == 0L) {
|
||||||
|
n /= f
|
||||||
|
c++
|
||||||
|
}
|
||||||
|
if (c >= 2) out *= mPow(f, c / 2)
|
||||||
|
if (c % 2 == 1) n *= f
|
||||||
|
f = if (f == 2L) 3 else f + 2
|
||||||
|
if (f > 10000) break
|
||||||
|
}
|
||||||
|
return out to n
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mNorm(t: MTerm) {
|
||||||
|
if (!t.ok) return
|
||||||
|
if (t.rad > 1) {
|
||||||
|
val s = mSqrtReduce(t.rad) ?: run { t.ok = false; return }
|
||||||
|
t.b = mMulF(t.b, MFrac(s.first, 1)) ?: run { t.ok = false; return }
|
||||||
|
t.rad = s.second
|
||||||
|
}
|
||||||
|
if (t.rad <= 1) {
|
||||||
|
t.a = mAddF(t.a, t.b) ?: run { t.ok = false; return }
|
||||||
|
t.b = MFrac()
|
||||||
|
t.rad = 0
|
||||||
|
}
|
||||||
|
if (t.b.n == 0L) t.rad = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mFail() = MTerm(ok = false)
|
||||||
|
|
||||||
|
private fun mAdd(x: MTerm, y: MTerm): MTerm {
|
||||||
|
if (!x.ok || !y.ok) return mFail()
|
||||||
|
val o = MTerm()
|
||||||
|
o.a = mAddF(x.a, y.a) ?: return mFail()
|
||||||
|
o.p = mAddF(x.p, y.p) ?: return mFail()
|
||||||
|
if (x.rad == 0L && y.rad == 0L) {
|
||||||
|
o.b = MFrac()
|
||||||
|
o.rad = 0
|
||||||
|
} else if (x.rad == 0L) {
|
||||||
|
o.b = y.b
|
||||||
|
o.rad = y.rad
|
||||||
|
} else if (y.rad == 0L || x.rad == y.rad) {
|
||||||
|
o.b = mAddF(x.b, y.b) ?: return mFail()
|
||||||
|
o.rad = x.rad
|
||||||
|
} else return mFail()
|
||||||
|
mNorm(o)
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mScale(x: MTerm, f: MFrac): MTerm {
|
||||||
|
if (!x.ok) return mFail()
|
||||||
|
val o = MTerm()
|
||||||
|
o.a = mMulF(x.a, f) ?: return mFail()
|
||||||
|
o.b = mMulF(x.b, f) ?: return mFail()
|
||||||
|
o.p = mMulF(x.p, f) ?: return mFail()
|
||||||
|
o.rad = x.rad
|
||||||
|
mNorm(o)
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mMulT(x: MTerm, y: MTerm): MTerm {
|
||||||
|
if (!x.ok || !y.ok) return mFail()
|
||||||
|
if ((x.p.n != 0L && (y.p.n != 0L || y.rad != 0L)) ||
|
||||||
|
(y.p.n != 0L && (x.p.n != 0L || x.rad != 0L))
|
||||||
|
) return mFail()
|
||||||
|
if (x.rad != 0L && y.rad != 0L && x.rad != y.rad) return mFail()
|
||||||
|
val o = MTerm()
|
||||||
|
o.a = mMulF(x.a, y.a) ?: return mFail()
|
||||||
|
if (x.rad == 0L && y.rad == 0L) {
|
||||||
|
o.b = MFrac()
|
||||||
|
o.rad = 0
|
||||||
|
} else if (x.rad == 0L) {
|
||||||
|
o.b = mMulF(x.a, y.b) ?: return mFail()
|
||||||
|
o.rad = y.rad
|
||||||
|
} else if (y.rad == 0L) {
|
||||||
|
o.b = mMulF(y.a, x.b) ?: return mFail()
|
||||||
|
o.rad = x.rad
|
||||||
|
} else {
|
||||||
|
val ab = mMulF(x.a, y.b) ?: return mFail()
|
||||||
|
val ba = mMulF(x.b, y.a) ?: return mFail()
|
||||||
|
val cross = mAddF(ab, ba) ?: return mFail()
|
||||||
|
val sq = mMul(x.rad, y.rad) ?: return mFail()
|
||||||
|
val bb = mMulF(x.b, y.b) ?: return mFail()
|
||||||
|
val extra = mMulF(bb, MFrac(sq, 1)) ?: return mFail()
|
||||||
|
o.a = mAddF(o.a, extra) ?: return mFail()
|
||||||
|
o.b = cross
|
||||||
|
o.rad = x.rad
|
||||||
|
}
|
||||||
|
o.p = if (x.p.n != 0L) x.p else y.p
|
||||||
|
if ((y.a.n != 1L || y.a.d != 1L || y.b.n != 0L || y.rad != 0L || y.p.n != 0L) && x.p.n != 0L) {
|
||||||
|
o.p = mMulF(x.p, y.a) ?: return mFail()
|
||||||
|
}
|
||||||
|
if ((x.a.n != 1L || x.a.d != 1L || x.b.n != 0L || x.rad != 0L || x.p.n != 0L) &&
|
||||||
|
y.p.n != 0L && x.p.n == 0L
|
||||||
|
) {
|
||||||
|
o.p = mMulF(y.p, x.a) ?: return mFail()
|
||||||
|
}
|
||||||
|
mNorm(o)
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mDivT(x: MTerm, y: MTerm): MTerm {
|
||||||
|
if (!x.ok || !y.ok || y.p.n != 0L) return mFail()
|
||||||
|
if (y.rad == 0L) {
|
||||||
|
val f = mDivF(MFrac(1, 1), y.a) ?: return mFail()
|
||||||
|
return mScale(x, f)
|
||||||
|
}
|
||||||
|
if (x.p.n != 0L) return mFail()
|
||||||
|
val yb = mMulF(y.b, MFrac(y.rad, 1)) ?: return mFail()
|
||||||
|
val bb = mMulF(y.b, yb) ?: return mFail()
|
||||||
|
val aa = mMulF(y.a, y.a) ?: return mFail()
|
||||||
|
val den = mAddF(aa, MFrac(-bb.n, bb.d)) ?: return mFail()
|
||||||
|
val num = MTerm()
|
||||||
|
num.a = y.a
|
||||||
|
num.b = MFrac(-y.b.n, y.b.d)
|
||||||
|
num.rad = y.rad
|
||||||
|
val inv = mDivF(MFrac(1, 1), den) ?: return mFail()
|
||||||
|
return mMulT(x, mScale(num, inv))
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MTok {
|
||||||
|
class Num(val n: Long, val d: Long) : MTok()
|
||||||
|
object Sqrt : MTok()
|
||||||
|
object Pi : MTok()
|
||||||
|
object Add : MTok()
|
||||||
|
object Sub : MTok()
|
||||||
|
object Mul : MTok()
|
||||||
|
object Div : MTok()
|
||||||
|
object Lp : MTok()
|
||||||
|
object Rp : MTok()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mLex(s: String): List<MTok>? {
|
||||||
|
val toks = ArrayList<MTok>()
|
||||||
|
var i = 0
|
||||||
|
fun peek(k: Int) = if (i + k < s.length) s[i + k] else null
|
||||||
|
while (i < s.length) {
|
||||||
|
while (i < s.length && s[i].isWhitespace()) i++
|
||||||
|
if (i >= s.length) break
|
||||||
|
val ch = s[i]
|
||||||
|
if (ch.isDigit() || ((ch == '.' || ch == ',') && peek(1)?.isDigit() == true)) {
|
||||||
|
var whole = 0L
|
||||||
|
var saw = false
|
||||||
|
while (i < s.length && s[i].isDigit()) {
|
||||||
|
saw = true
|
||||||
|
whole = whole * 10 + (s[i] - '0')
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (i < s.length && (s[i] == '.' || s[i] == ',') && i + 1 < s.length && s[i + 1].isDigit()) {
|
||||||
|
i++
|
||||||
|
var frac = 0L
|
||||||
|
var div = 1L
|
||||||
|
while (i < s.length && s[i].isDigit()) {
|
||||||
|
frac = frac * 10 + (s[i] - '0')
|
||||||
|
div *= 10
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
val n = mAddL(mMul(whole, div) ?: return null, frac) ?: return null
|
||||||
|
toks.add(MTok.Num(n, div))
|
||||||
|
} else if (saw) {
|
||||||
|
toks.add(MTok.Num(whole, 1))
|
||||||
|
} else return null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when (ch) {
|
||||||
|
'+' -> toks.add(MTok.Add)
|
||||||
|
'-', '\u2212', '\u2013' -> toks.add(MTok.Sub)
|
||||||
|
'*', '\u00D7', '\u00B7' -> toks.add(MTok.Mul)
|
||||||
|
'/', ':' -> toks.add(MTok.Div)
|
||||||
|
'(' -> toks.add(MTok.Lp)
|
||||||
|
')' -> toks.add(MTok.Rp)
|
||||||
|
'\u03C0' -> toks.add(MTok.Pi)
|
||||||
|
'\u221A' -> toks.add(MTok.Sqrt)
|
||||||
|
else -> {
|
||||||
|
if (ch in 'A'..'Z' || ch in 'a'..'z') {
|
||||||
|
val start = i
|
||||||
|
while (i < s.length && (s[i] in 'A'..'Z' || s[i] in 'a'..'z')) i++
|
||||||
|
when (s.substring(start, i).lowercase()) {
|
||||||
|
"pi" -> toks.add(MTok.Pi)
|
||||||
|
"sqrt" -> toks.add(MTok.Sqrt)
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return toks
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MParse(val t: List<MTok>) {
|
||||||
|
var i = 0
|
||||||
|
fun peek() = if (i < t.size) t[i] else null
|
||||||
|
fun take(): MTok? = if (i < t.size) t[i++] else null
|
||||||
|
fun at(k: Int) = if (k in t.indices) t[k] else null
|
||||||
|
|
||||||
|
fun expr(): MTerm? {
|
||||||
|
var left = term() ?: return null
|
||||||
|
while (true) {
|
||||||
|
val p = peek()
|
||||||
|
if (p !is MTok.Add && p !is MTok.Sub) break
|
||||||
|
take()
|
||||||
|
val r = term() ?: return null
|
||||||
|
left = if (p is MTok.Sub) mAdd(left, mScale(r, MFrac(-1, 1))) else mAdd(left, r)
|
||||||
|
if (!left.ok) return null
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
fun term(): MTerm? {
|
||||||
|
var left = unary() ?: return null
|
||||||
|
while (true) {
|
||||||
|
val p = peek()
|
||||||
|
if (p !is MTok.Mul && p !is MTok.Div) break
|
||||||
|
take()
|
||||||
|
val r = unary() ?: return null
|
||||||
|
left = if (p is MTok.Div) mDivT(left, r) else mMulT(left, r)
|
||||||
|
if (!left.ok) return null
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unary(): MTerm? {
|
||||||
|
if (peek() is MTok.Sub) {
|
||||||
|
take()
|
||||||
|
val v = unary() ?: return null
|
||||||
|
return mScale(v, MFrac(-1, 1))
|
||||||
|
}
|
||||||
|
if (peek() is MTok.Add) {
|
||||||
|
take()
|
||||||
|
return unary()
|
||||||
|
}
|
||||||
|
return factor()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun factor(): MTerm? {
|
||||||
|
var left = primary() ?: return null
|
||||||
|
while (true) {
|
||||||
|
when (peek()) {
|
||||||
|
is MTok.Sqrt, is MTok.Pi, is MTok.Lp -> {
|
||||||
|
val r = primary() ?: return null
|
||||||
|
left = mMulT(left, r)
|
||||||
|
if (!left.ok) return null
|
||||||
|
}
|
||||||
|
is MTok.Num -> return null
|
||||||
|
else -> return left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun primary(): MTerm? {
|
||||||
|
when (val p = take()) {
|
||||||
|
is MTok.Num -> {
|
||||||
|
val n = p.n
|
||||||
|
val d = p.d
|
||||||
|
if (peek() is MTok.Div && at(i + 1) is MTok.Num) {
|
||||||
|
take()
|
||||||
|
val n2 = take() as? MTok.Num ?: return null
|
||||||
|
if (n2.n <= 0 || n2.d != 1L || d != 1L) return null
|
||||||
|
val o = MTerm()
|
||||||
|
o.a = mSet(n, n2.n) ?: return null
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
if (d == 1L && n >= 0 && peek() is MTok.Num) {
|
||||||
|
val saved = i
|
||||||
|
val n2 = take() as MTok.Num
|
||||||
|
val n3tok = at(i + 1)
|
||||||
|
if (peek() is MTok.Div && n3tok is MTok.Num && n2.d == 1L && n3tok.d == 1L && n2.n >= 0 && n3tok.n > 0) {
|
||||||
|
take()
|
||||||
|
take()
|
||||||
|
val frac = mSet(n2.n, n3tok.n) ?: return null
|
||||||
|
val whole = MTerm()
|
||||||
|
whole.a = MFrac(n, 1)
|
||||||
|
val part = MTerm()
|
||||||
|
part.a = frac
|
||||||
|
return mAdd(whole, part)
|
||||||
|
}
|
||||||
|
i = saved
|
||||||
|
}
|
||||||
|
val o = MTerm()
|
||||||
|
o.a = mSet(n, d) ?: return null
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
is MTok.Pi -> {
|
||||||
|
val o = MTerm()
|
||||||
|
o.p = MFrac(1, 1)
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
is MTok.Sqrt -> {
|
||||||
|
if (peek() is MTok.Lp) {
|
||||||
|
take()
|
||||||
|
val inner = expr() ?: return null
|
||||||
|
if (take() !is MTok.Rp) return null
|
||||||
|
return mSqrtTerm(inner)
|
||||||
|
}
|
||||||
|
val num = peek()
|
||||||
|
if (num is MTok.Num && num.d == 1L && num.n >= 0) {
|
||||||
|
take()
|
||||||
|
val inner = MTerm()
|
||||||
|
inner.a = MFrac(num.n, 1)
|
||||||
|
return mSqrtTerm(inner)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
is MTok.Lp -> {
|
||||||
|
val inner = expr() ?: return null
|
||||||
|
if (take() !is MTok.Rp) return null
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mSqrtTerm(inner: MTerm): MTerm? {
|
||||||
|
if (!inner.ok || inner.p.n != 0L || inner.rad != 0L || inner.b.n != 0L) return null
|
||||||
|
if (inner.a.n < 0 || inner.a.d != 1L) return null
|
||||||
|
val s = mSqrtReduce(inner.a.n) ?: return null
|
||||||
|
val o = MTerm()
|
||||||
|
o.b = MFrac(s.first, 1)
|
||||||
|
o.rad = s.second
|
||||||
|
mNorm(o)
|
||||||
|
return if (o.ok) o else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mStripUnits(raw: String): String {
|
||||||
|
var s = raw.trim()
|
||||||
|
val units = listOf("procenta", "procent", "cm", "mm", "dm", "km", "m")
|
||||||
|
var cut = true
|
||||||
|
while (cut) {
|
||||||
|
cut = false
|
||||||
|
s = s.trimEnd()
|
||||||
|
if (s.endsWith("°") || s.endsWith("%") || s.endsWith(".")) {
|
||||||
|
s = s.dropLast(1)
|
||||||
|
cut = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (u in units) {
|
||||||
|
if (s.length <= u.length) continue
|
||||||
|
if (s.lowercase().endsWith(u)) {
|
||||||
|
val keep = s.dropLast(u.length)
|
||||||
|
if (keep.lastOrNull()?.isLetter() == true) continue
|
||||||
|
s = keep
|
||||||
|
cut = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mPrep(raw: String): String {
|
||||||
|
var s = raw.trim()
|
||||||
|
val pairs = listOf('(' to ')', '[' to ']', '{' to '}')
|
||||||
|
while (s.length >= 2 && pairs.any { it.first == s.first() && it.second == s.last() }) {
|
||||||
|
s = s.substring(1, s.length - 1).trim()
|
||||||
|
}
|
||||||
|
return mStripUnits(s).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mParseTerm(s: String): MTerm? {
|
||||||
|
val text = mPrep(s)
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
val toks = mLex(text) ?: return null
|
||||||
|
if (toks.isEmpty()) return null
|
||||||
|
val p = MParse(toks)
|
||||||
|
val v = p.expr() ?: return null
|
||||||
|
if (p.i != toks.size || !v.ok) return null
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mExact(x: MTerm, y: MTerm) =
|
||||||
|
x.ok && y.ok && mSameF(x.a, y.a) && mSameF(x.b, y.b) && x.rad == y.rad && mSameF(x.p, y.p)
|
||||||
|
|
||||||
|
private fun mDouble(t: MTerm): Double {
|
||||||
|
var v = t.a.n.toDouble() / t.a.d + t.p.n.toDouble() / t.p.d * Math.PI
|
||||||
|
if (t.b.n != 0L && t.rad > 0) v += t.b.n.toDouble() / t.b.d * Math.sqrt(t.rad.toDouble())
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mClose(x: MTerm, y: MTerm): Boolean {
|
||||||
|
if (!x.ok || !y.ok) return false
|
||||||
|
if (mExact(x, y)) return true
|
||||||
|
if (x.p.n != 0L || y.p.n != 0L) return false
|
||||||
|
val diff = Math.abs(mDouble(x) - mDouble(y))
|
||||||
|
return if (x.b.n != 0L || y.b.n != 0L) diff <= 0.001 else diff <= 0.0005
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mFold(s: String): String {
|
||||||
|
val out = StringBuilder()
|
||||||
|
for (ch in s.lowercase()) {
|
||||||
|
when (ch) {
|
||||||
|
'á', 'à', 'ä', 'â' -> out.append('a')
|
||||||
|
'č', 'ć' -> out.append('c')
|
||||||
|
'ď' -> out.append('d')
|
||||||
|
'é', 'ě', 'ë', 'ê' -> out.append('e')
|
||||||
|
'í', 'ï' -> out.append('i')
|
||||||
|
'ň' -> out.append('n')
|
||||||
|
'ó', 'ö', 'ô' -> out.append('o')
|
||||||
|
'ř' -> out.append('r')
|
||||||
|
'š', 'ś' -> out.append('s')
|
||||||
|
'ť' -> out.append('t')
|
||||||
|
'ú', 'ů', 'ü' -> out.append('u')
|
||||||
|
'ý' -> out.append('y')
|
||||||
|
'ž', 'ź' -> out.append('z')
|
||||||
|
else -> if (ch.isLetter() || ch.isDigit()) out.append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mSplit(raw: String): List<String>? {
|
||||||
|
val s = raw.trim()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
val semi = s.contains(';')
|
||||||
|
val parts = ArrayList<String>()
|
||||||
|
var rest = s
|
||||||
|
while (rest.isNotEmpty()) {
|
||||||
|
val idx = if (semi) rest.indexOf(';') else rest.indexOf(", ")
|
||||||
|
val step = if (semi) 1 else 2
|
||||||
|
val piece: String
|
||||||
|
val next: String
|
||||||
|
if (idx < 0) {
|
||||||
|
piece = rest.trim()
|
||||||
|
next = ""
|
||||||
|
} else {
|
||||||
|
piece = rest.substring(0, idx).trim()
|
||||||
|
next = rest.substring(idx + step)
|
||||||
|
}
|
||||||
|
if (piece.isEmpty()) return null
|
||||||
|
parts.add(piece)
|
||||||
|
if (idx < 0) break
|
||||||
|
rest = next
|
||||||
|
}
|
||||||
|
return parts.ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mathAnswerOk(spec: String?, user: String?): Boolean {
|
||||||
|
if (spec == null || user == null) return false
|
||||||
|
val bodySpec = spec.trim()
|
||||||
|
if (bodySpec.startsWith("word:")) {
|
||||||
|
val opts = bodySpec.removePrefix("word:").split('|').map { mFold(it) }
|
||||||
|
return opts.contains(mFold(user))
|
||||||
|
}
|
||||||
|
if (bodySpec.startsWith("set:") || bodySpec.startsWith("pair:")) {
|
||||||
|
val ordered = bodySpec.startsWith("pair:")
|
||||||
|
val body = bodySpec.removePrefix(if (ordered) "pair:" else "set:")
|
||||||
|
val want = mSplit(body) ?: return false
|
||||||
|
val got = mSplit(user) ?: return false
|
||||||
|
if (want.size != got.size || want.isEmpty()) return false
|
||||||
|
val wt = want.map { mParseTerm(it) ?: return false }
|
||||||
|
val gt = got.map { mParseTerm(it) ?: return false }
|
||||||
|
if (ordered) return wt.indices.all { mClose(wt[it], gt[it]) }
|
||||||
|
val used = BooleanArray(gt.size)
|
||||||
|
for (w in wt) {
|
||||||
|
var hit = gt.indices.firstOrNull { !used[it] && mExact(w, gt[it]) }
|
||||||
|
if (hit == null) hit = gt.indices.firstOrNull { !used[it] && mClose(w, gt[it]) }
|
||||||
|
if (hit == null) return false
|
||||||
|
used[hit] = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!bodySpec.startsWith("num:")) return false
|
||||||
|
val tw = mParseTerm(bodySpec.removePrefix("num:")) ?: return false
|
||||||
|
val tg = mParseTerm(user) ?: return false
|
||||||
|
return mClose(tw, tg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mathIsDraw(spec: String?): Boolean {
|
||||||
|
if (spec == null) return false
|
||||||
|
return spec.startsWith("square:") || spec.startsWith("mid:") || spec.startsWith("right:") ||
|
||||||
|
spec.startsWith("para:") || spec.startsWith("points:")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mathDrawNeed(spec: String?): Int {
|
||||||
|
if (spec == null) return 0
|
||||||
|
if (spec.startsWith("square:")) return 2
|
||||||
|
if (spec.startsWith("mid:") || spec.startsWith("right:") || spec.startsWith("para:")) return 1
|
||||||
|
if (spec.startsWith("points:")) {
|
||||||
|
return spec.removePrefix("points:").split(';').count { !it.startsWith("fix:") }
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mParseXY(s: String): Pair<Int, Int>? {
|
||||||
|
val bits = s.trim().split(',')
|
||||||
|
if (bits.size != 2) return null
|
||||||
|
val x = bits[0].trim().toIntOrNull() ?: return null
|
||||||
|
val y = bits[1].trim().toIntOrNull() ?: return null
|
||||||
|
return x to y
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mathDrawGiven(spec: String?): List<Pair<Int, Int>> {
|
||||||
|
if (spec == null) return emptyList()
|
||||||
|
if (spec.startsWith("points:")) {
|
||||||
|
return spec.removePrefix("points:").split(';').mapNotNull { part ->
|
||||||
|
if (!part.startsWith("fix:")) null else mParseXY(part.removePrefix("fix:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body: String
|
||||||
|
val n: Int
|
||||||
|
when {
|
||||||
|
spec.startsWith("para:") -> { body = spec.removePrefix("para:"); n = 3 }
|
||||||
|
spec.startsWith("square:") -> { body = spec.removePrefix("square:"); n = 2 }
|
||||||
|
spec.startsWith("right:") -> { body = spec.removePrefix("right:"); n = 2 }
|
||||||
|
spec.startsWith("mid:") -> { body = spec.removePrefix("mid:"); n = 2 }
|
||||||
|
else -> return emptyList()
|
||||||
|
}
|
||||||
|
return body.split(';').take(n).mapNotNull { mParseXY(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mOnBoard(p: Pair<Int, Int>) = p.first in 0..10 && p.second in 0..8
|
||||||
|
|
||||||
|
fun mathDrawOk(spec: String?, pts: List<Pair<Int, Int>>): Boolean {
|
||||||
|
if (spec == null || mathDrawNeed(spec) != pts.size) return false
|
||||||
|
if (pts.any { !mOnBoard(it) }) return false
|
||||||
|
if (pts.map { "${it.first},${it.second}" }.toSet().size != pts.size) return false
|
||||||
|
val g = mathDrawGiven(spec)
|
||||||
|
if (spec.startsWith("mid:")) {
|
||||||
|
if (g.size != 2 || (g[0].first + g[1].first) % 2 != 0 || (g[0].second + g[1].second) % 2 != 0) return false
|
||||||
|
return pts[0] == (g[0].first + g[1].first) / 2 to (g[0].second + g[1].second) / 2
|
||||||
|
}
|
||||||
|
if (spec.startsWith("para:")) {
|
||||||
|
if (g.size != 3) return false
|
||||||
|
return pts[0] == g[1].first + g[2].first - g[0].first to g[1].second + g[2].second - g[0].second
|
||||||
|
}
|
||||||
|
if (spec.startsWith("right:")) {
|
||||||
|
if (g.size != 2) return false
|
||||||
|
val c = pts[0]
|
||||||
|
if (c == g[0] || c == g[1]) return false
|
||||||
|
val ux = g[0].first - c.first
|
||||||
|
val uy = g[0].second - c.second
|
||||||
|
val vx = g[1].first - c.first
|
||||||
|
val vy = g[1].second - c.second
|
||||||
|
return ux * vx + uy * vy == 0 && (ux != 0 || uy != 0) && (vx != 0 || vy != 0)
|
||||||
|
}
|
||||||
|
if (spec.startsWith("square:")) {
|
||||||
|
if (g.size != 2 || pts.size != 2) return false
|
||||||
|
val a = g[0]
|
||||||
|
val b = g[1]
|
||||||
|
val dx = b.first - a.first
|
||||||
|
val dy = b.second - a.second
|
||||||
|
if (dx == 0 && dy == 0) return false
|
||||||
|
val c1 = b.first - dy to b.second + dx
|
||||||
|
val d1 = a.first - dy to a.second + dx
|
||||||
|
val c2 = b.first + dy to b.second - dx
|
||||||
|
val d2 = a.first + dy to a.second - dx
|
||||||
|
fun same(p: Pair<Int, Int>, q: Pair<Int, Int>, r: Pair<Int, Int>, s: Pair<Int, Int>) =
|
||||||
|
(p == r && q == s) || (p == s && q == r)
|
||||||
|
if ((!mOnBoard(c1) || !mOnBoard(d1)) && (!mOnBoard(c2) || !mOnBoard(d2))) return false
|
||||||
|
return same(pts[0], pts[1], c1, d1) || same(pts[0], pts[1], c2, d2)
|
||||||
|
}
|
||||||
|
if (spec.startsWith("points:")) {
|
||||||
|
val want = spec.removePrefix("points:").split(';').mapNotNull { part ->
|
||||||
|
if (part.startsWith("fix:")) null else mParseXY(part)
|
||||||
|
}
|
||||||
|
if (want.size != pts.size) return false
|
||||||
|
val used = BooleanArray(pts.size)
|
||||||
|
for (w in want) {
|
||||||
|
val j = pts.indices.firstOrNull { !used[it] && pts[it] == w } ?: return false
|
||||||
|
used[j] = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
180
android/shared/src/org/maturita/maturita/data/Content.kt
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
package org.maturita.maturita.data
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class Content(root: JSONObject) {
|
||||||
|
val raw = J(root)
|
||||||
|
val i18n: Map<String, Pair<String?, String?>> = run {
|
||||||
|
val obj = root.optJSONObject("i18n") ?: JSONObject()
|
||||||
|
obj.keys().asSequence().associateWith { key ->
|
||||||
|
val e = obj.optJSONObject(key)
|
||||||
|
val cs = e?.optString("cs")?.takeIf { it.isNotEmpty() && it != "null" }
|
||||||
|
val en = e?.optString("en")?.takeIf { it.isNotEmpty() && it != "null" }
|
||||||
|
cs to en
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val subjects = raw.arr("subjects")
|
||||||
|
val german = raw.arr("german")
|
||||||
|
val net = raw.obj("net") ?: J(JSONObject())
|
||||||
|
val netLessons = net.arr("lessons")
|
||||||
|
val netTasks = net.arr("tasks")
|
||||||
|
val netAnswers: List<List<J>> = run {
|
||||||
|
val a = net.o.optJSONArray("answers") ?: return@run emptyList()
|
||||||
|
(0 until a.length()).map { i ->
|
||||||
|
val block = a.optJSONArray(i) ?: org.json.JSONArray()
|
||||||
|
(0 until block.length()).mapNotNull { j ->
|
||||||
|
block.optJSONObject(j)?.let { J(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hw = raw.arr("hw")
|
||||||
|
val net2 = raw.arr("net2")
|
||||||
|
val net3 = raw.arr("net3")
|
||||||
|
val net4 = raw.arr("net4")
|
||||||
|
val hw2 = raw.arr("hw2")
|
||||||
|
val hw3 = raw.arr("hw3")
|
||||||
|
val hw4 = raw.arr("hw4")
|
||||||
|
val on = raw.arr("on")
|
||||||
|
val on2 = raw.arr("on2")
|
||||||
|
val on3 = raw.arr("on3")
|
||||||
|
val on4 = raw.arr("on4")
|
||||||
|
val en = raw.arr("en")
|
||||||
|
val en2 = raw.arr("en2")
|
||||||
|
val en3 = raw.arr("en3")
|
||||||
|
val en4 = raw.arr("en4")
|
||||||
|
val de = raw.arr("de")
|
||||||
|
val de2 = raw.arr("de2")
|
||||||
|
val de3 = raw.arr("de3")
|
||||||
|
val de4 = raw.arr("de4")
|
||||||
|
val lit = raw.arr("lit")
|
||||||
|
val lit2 = raw.arr("lit2")
|
||||||
|
val lit3 = raw.arr("lit3")
|
||||||
|
val lit4 = raw.arr("lit4")
|
||||||
|
val chem = raw.arr("chem")
|
||||||
|
val bio = raw.arr("bio")
|
||||||
|
val fyz = raw.arr("fyz")
|
||||||
|
val fyz2 = raw.arr("fyz2")
|
||||||
|
val fyz3 = raw.arr("fyz3")
|
||||||
|
val fyz4 = raw.arr("fyz4")
|
||||||
|
val tg = raw.arr("tg")
|
||||||
|
val tg2 = raw.arr("tg2")
|
||||||
|
val tg3 = raw.arr("tg3")
|
||||||
|
val tg4 = raw.arr("tg4")
|
||||||
|
val pg = raw.arr("pg")
|
||||||
|
val pg2 = raw.arr("pg2")
|
||||||
|
val pg3 = raw.arr("pg3")
|
||||||
|
val pg4 = raw.arr("pg4")
|
||||||
|
val sw = raw.arr("sw")
|
||||||
|
val sw2 = raw.arr("sw2")
|
||||||
|
val sw3 = raw.arr("sw3")
|
||||||
|
val sw4 = raw.arr("sw4")
|
||||||
|
val cs = raw.arr("cs")
|
||||||
|
val cs2 = raw.arr("cs2")
|
||||||
|
val cs3 = raw.arr("cs3")
|
||||||
|
val cs4 = raw.arr("cs4")
|
||||||
|
val mat0 = raw.arr("mat0")
|
||||||
|
val mat = raw.arr("mat")
|
||||||
|
val mat2 = raw.arr("mat2")
|
||||||
|
val mat3 = raw.arr("mat3")
|
||||||
|
val mat4 = raw.arr("mat4")
|
||||||
|
val mluvnice = raw.arr("mluvnice")
|
||||||
|
val books = raw.arr("books")
|
||||||
|
val changelog = raw.arr("changelog")
|
||||||
|
val progressKeys = stringMap(root.optJSONObject("progress")?.optJSONObject("keys"))
|
||||||
|
val progressLegacy = stringMap(root.optJSONObject("progress")?.optJSONObject("legacy"))
|
||||||
|
|
||||||
|
fun germanUnit(id: Int) = german.firstOrNull { it.int("id") == id }
|
||||||
|
fun netLesson(id: Int) = netLessons.firstOrNull { it.int("id") == id }
|
||||||
|
fun hwLesson(id: Int) = hw.firstOrNull { it.int("id") == id }
|
||||||
|
fun itLessons(course: String) = when (course) {
|
||||||
|
"net2" -> net2
|
||||||
|
"net3" -> net3
|
||||||
|
"net4" -> net4
|
||||||
|
"hw2" -> hw2
|
||||||
|
"hw3" -> hw3
|
||||||
|
"hw4" -> hw4
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
fun itLesson(course: String, id: Int) = itLessons(course).firstOrNull { it.int("id") == id }
|
||||||
|
fun onLesson(id: Int) = on.firstOrNull { it.int("id") == id }
|
||||||
|
fun on2Lesson(id: Int) = on2.firstOrNull { it.int("id") == id }
|
||||||
|
fun on3Lesson(id: Int) = on3.firstOrNull { it.int("id") == id }
|
||||||
|
fun on4Lesson(id: Int) = on4.firstOrNull { it.int("id") == id }
|
||||||
|
fun enYear(year: Int) = when (year) {
|
||||||
|
2 -> en2
|
||||||
|
3 -> en3
|
||||||
|
4 -> en4
|
||||||
|
else -> en
|
||||||
|
}
|
||||||
|
fun enLesson(year: Int, id: Int) = enYear(year).firstOrNull { it.int("id") == id }
|
||||||
|
fun deYear(year: Int) = when (year) {
|
||||||
|
2 -> de2
|
||||||
|
3 -> de3
|
||||||
|
4 -> de4
|
||||||
|
else -> de
|
||||||
|
}
|
||||||
|
fun deLesson(year: Int, id: Int) = deYear(year).firstOrNull { it.int("id") == id }
|
||||||
|
fun litLesson(id: Int) = lit.firstOrNull { it.int("id") == id }
|
||||||
|
fun lit2Lesson(id: Int) = lit2.firstOrNull { it.int("id") == id }
|
||||||
|
fun lit3Lesson(id: Int) = lit3.firstOrNull { it.int("id") == id }
|
||||||
|
fun lit4Lesson(id: Int) = lit4.firstOrNull { it.int("id") == id }
|
||||||
|
fun chemLesson(id: Int) = chem.firstOrNull { it.int("id") == id }
|
||||||
|
fun bioLesson(id: Int) = bio.firstOrNull { it.int("id") == id }
|
||||||
|
fun fyzLesson(id: Int) = fyz.firstOrNull { it.int("id") == id }
|
||||||
|
fun fyz2Lesson(id: Int) = fyz2.firstOrNull { it.int("id") == id }
|
||||||
|
fun fyz3Lesson(id: Int) = fyz3.firstOrNull { it.int("id") == id }
|
||||||
|
fun fyz4Lesson(id: Int) = fyz4.firstOrNull { it.int("id") == id }
|
||||||
|
fun tgLesson(id: Int) = tg.firstOrNull { it.int("id") == id }
|
||||||
|
fun tg2Lesson(id: Int) = tg2.firstOrNull { it.int("id") == id }
|
||||||
|
fun tg3Lesson(id: Int) = tg3.firstOrNull { it.int("id") == id }
|
||||||
|
fun tg4Lesson(id: Int) = tg4.firstOrNull { it.int("id") == id }
|
||||||
|
fun pgLesson(id: Int) = pg.firstOrNull { it.int("id") == id }
|
||||||
|
fun pg2Lesson(id: Int) = pg2.firstOrNull { it.int("id") == id }
|
||||||
|
fun pg3Lesson(id: Int) = pg3.firstOrNull { it.int("id") == id }
|
||||||
|
fun pg4Lesson(id: Int) = pg4.firstOrNull { it.int("id") == id }
|
||||||
|
fun swLesson(id: Int) = sw.firstOrNull { it.int("id") == id }
|
||||||
|
fun sw2Lesson(id: Int) = sw2.firstOrNull { it.int("id") == id }
|
||||||
|
fun sw3Lesson(id: Int) = sw3.firstOrNull { it.int("id") == id }
|
||||||
|
fun sw4Lesson(id: Int) = sw4.firstOrNull { it.int("id") == id }
|
||||||
|
fun csLesson(id: Int) = cs.firstOrNull { it.int("id") == id }
|
||||||
|
fun cs2Lesson(id: Int) = cs2.firstOrNull { it.int("id") == id }
|
||||||
|
fun cs3Lesson(id: Int) = cs3.firstOrNull { it.int("id") == id }
|
||||||
|
fun cs4Lesson(id: Int) = cs4.firstOrNull { it.int("id") == id }
|
||||||
|
fun mat0Lesson(id: Int) = mat0.firstOrNull { it.int("id") == id }
|
||||||
|
fun matLesson(id: Int) = mat.firstOrNull { it.int("id") == id }
|
||||||
|
fun mat2Lesson(id: Int) = mat2.firstOrNull { it.int("id") == id }
|
||||||
|
fun mat3Lesson(id: Int) = mat3.firstOrNull { it.int("id") == id }
|
||||||
|
fun mat4Lesson(id: Int) = mat4.firstOrNull { it.int("id") == id }
|
||||||
|
fun mluv(n: Int) = mluvnice.firstOrNull { it.int("id") == n }
|
||||||
|
fun book(id: String) = books.firstOrNull { it.str("id") == id }
|
||||||
|
|
||||||
|
fun tr(key: String?, lang: UiLang): String {
|
||||||
|
if (key.isNullOrEmpty()) return ""
|
||||||
|
val hit = i18n[key]
|
||||||
|
return if (lang == UiLang.En) {
|
||||||
|
hit?.second ?: key
|
||||||
|
} else {
|
||||||
|
hit?.first ?: key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fmt(key: String, lang: UiLang, vararg args: Any): String {
|
||||||
|
val template = tr(key, lang)
|
||||||
|
return try {
|
||||||
|
String.format(template, *args)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private fun stringMap(o: JSONObject?): Map<String, String> =
|
||||||
|
o?.keys()?.asSequence()?.associateWith { o.optString(it) } ?: emptyMap()
|
||||||
|
|
||||||
|
fun parse(text: String?): Content = try {
|
||||||
|
Content(JSONObject(text ?: "{}"))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
Content(JSONObject())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
android/shared/src/org/maturita/maturita/data/Json.kt
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package org.maturita.maturita.data
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class J(val o: JSONObject) {
|
||||||
|
fun has(key: String) = o.has(key) && !o.isNull(key)
|
||||||
|
fun str(key: String, fallback: String = "") =
|
||||||
|
if (!has(key)) fallback else o.optString(key, fallback)
|
||||||
|
fun strOrNull(key: String): String? =
|
||||||
|
if (!has(key)) null else o.optString(key).takeIf { it.isNotEmpty() && it != "null" }
|
||||||
|
fun bool(key: String, fallback: Boolean = false) = o.optBoolean(key, fallback)
|
||||||
|
fun int(key: String, fallback: Int = 0) = o.optInt(key, fallback)
|
||||||
|
fun obj(key: String): J? = o.optJSONObject(key)?.let { J(it) }
|
||||||
|
fun exercise(n: Int): J? = o.optJSONObject("exercises")?.optJSONObject(n.toString())?.let { J(it) }
|
||||||
|
fun arr(key: String): List<J> {
|
||||||
|
val a = o.optJSONArray(key) ?: return emptyList()
|
||||||
|
return (0 until a.length()).mapNotNull { i -> a.optJSONObject(i)?.let { J(it) } }
|
||||||
|
}
|
||||||
|
fun strs(key: String): List<String> {
|
||||||
|
val a = o.optJSONArray(key) ?: return emptyList()
|
||||||
|
return (0 until a.length()).mapNotNull { i ->
|
||||||
|
if (a.isNull(i)) null else a.optString(i).takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun strsOrEmpty(key: String): List<String?> {
|
||||||
|
val a = o.optJSONArray(key) ?: return emptyList()
|
||||||
|
return (0 until a.length()).map { i ->
|
||||||
|
if (a.isNull(i)) null else a.optString(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun intRows(key: String): List<List<String>> {
|
||||||
|
val a = o.optJSONArray(key) ?: return emptyList()
|
||||||
|
return (0 until a.length()).map { i ->
|
||||||
|
val row = a.optJSONArray(i) ?: JSONArray()
|
||||||
|
(0 until row.length()).map { j -> row.optString(j) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun answerList(): List<String> {
|
||||||
|
val a = o.optJSONArray("answers")
|
||||||
|
if (a != null) {
|
||||||
|
return (0 until a.length()).mapNotNull { i ->
|
||||||
|
if (a.isNull(i)) null else a.optString(i).takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listOfNotNull(strOrNull("answers"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun JSONArray.objects(): List<J> =
|
||||||
|
(0 until length()).mapNotNull { i -> optJSONObject(i)?.let { J(it) } }
|
||||||
489
android/shared/src/org/maturita/maturita/data/ProgressStore.kt
Normal file
|
|
@ -0,0 +1,489 @@
|
||||||
|
package org.maturita.maturita.data
|
||||||
|
|
||||||
|
import org.maturita.maturita.platform.Prefs
|
||||||
|
|
||||||
|
class ProgressStore(private val prefs: Prefs, content: Content) {
|
||||||
|
private val ids = content.progressKeys
|
||||||
|
|
||||||
|
init {
|
||||||
|
migrate(content.progressLegacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress is stored under each lesson's id from content/, so lessons can be
|
||||||
|
* reordered or renamed without losing it. `address` is the in-app position. */
|
||||||
|
private fun key(address: String) = "done." + (ids[address] ?: address)
|
||||||
|
private fun done(address: String) = prefs.getBoolean(key(address), false)
|
||||||
|
private fun mark(address: String) { prefs.edit().putBoolean(key(address), true).apply() }
|
||||||
|
|
||||||
|
private fun migrate(legacy: Map<String, String>) {
|
||||||
|
if (prefs.getBoolean("progress_ids_v2", false)) return
|
||||||
|
val edit = prefs.edit()
|
||||||
|
for ((old, id) in legacy) {
|
||||||
|
if (prefs.getBoolean(old, false)) edit.putBoolean("done.$id", true)
|
||||||
|
}
|
||||||
|
edit.putBoolean("progress_ids_v2", true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeId: ThemeId
|
||||||
|
get() = ThemeId.entries.getOrElse(prefs.getInt("theme", 0)) { ThemeId.Catppuccin }
|
||||||
|
set(v) { prefs.edit().putInt("theme", v.ordinal).apply() }
|
||||||
|
|
||||||
|
var mode: ColorMode
|
||||||
|
get() = if (prefs.getString("mode", "light") == "dark") ColorMode.Dark else ColorMode.Light
|
||||||
|
set(v) { prefs.edit().putString("mode", if (v == ColorMode.Light) "light" else "dark").apply() }
|
||||||
|
|
||||||
|
var lang: UiLang
|
||||||
|
get() = if (prefs.getString("lang", "cs") == "en") UiLang.En else UiLang.Cs
|
||||||
|
set(v) { prefs.edit().putString("lang", if (v == UiLang.En) "en" else "cs").apply() }
|
||||||
|
|
||||||
|
var seenCommit: String
|
||||||
|
get() = prefs.getString("seen_commit", "") ?: ""
|
||||||
|
set(v) { prefs.edit().putString("seen_commit", v).apply() }
|
||||||
|
|
||||||
|
fun germanDone(unit: Int, ex: Int) = done("g.$unit.$ex")
|
||||||
|
fun markGerman(unit: Int, ex: Int) { mark("g.$unit.$ex") }
|
||||||
|
fun vocabDone(unit: Int) = done("g.$unit.vocab")
|
||||||
|
fun markVocab(unit: Int) { mark("g.$unit.vocab") }
|
||||||
|
|
||||||
|
fun netDone(id: Int) = done("net.$id")
|
||||||
|
fun markNet(id: Int) { mark("net.$id") }
|
||||||
|
|
||||||
|
fun hwDone(id: Int) = done("hw.$id")
|
||||||
|
fun markHw(id: Int) { mark("hw.$id") }
|
||||||
|
|
||||||
|
fun onDone(id: Int) = done("on.$id")
|
||||||
|
fun markOn(id: Int) { mark("on.$id") }
|
||||||
|
|
||||||
|
fun on2Done(id: Int) = done("on2.$id")
|
||||||
|
fun markOn2(id: Int) { mark("on2.$id") }
|
||||||
|
|
||||||
|
fun on3Done(id: Int) = done("on3.$id")
|
||||||
|
fun markOn3(id: Int) { mark("on3.$id") }
|
||||||
|
|
||||||
|
fun on4Done(id: Int) = done("on4.$id")
|
||||||
|
fun markOn4(id: Int) { mark("on4.$id") }
|
||||||
|
|
||||||
|
fun enDone(year: Int, id: Int) = done("en.$year.$id")
|
||||||
|
fun markEn(year: Int, id: Int) { mark("en.$year.$id") }
|
||||||
|
fun deDone(year: Int, id: Int) = done("de.$year.$id")
|
||||||
|
fun markDe(year: Int, id: Int) { mark("de.$year.$id") }
|
||||||
|
fun courseDone(course: String, id: Int) = done("$course.$id")
|
||||||
|
fun markCourse(course: String, id: Int) { mark("$course.$id") }
|
||||||
|
|
||||||
|
fun litDone(id: Int) = done("lit.$id")
|
||||||
|
fun markLit(id: Int) { mark("lit.$id") }
|
||||||
|
fun lit2Done(id: Int) = done("lit2.$id")
|
||||||
|
fun markLit2(id: Int) { mark("lit2.$id") }
|
||||||
|
fun lit3Done(id: Int) = done("lit3.$id")
|
||||||
|
fun markLit3(id: Int) { mark("lit3.$id") }
|
||||||
|
fun lit4Done(id: Int) = done("lit4.$id")
|
||||||
|
fun markLit4(id: Int) { mark("lit4.$id") }
|
||||||
|
|
||||||
|
fun chemDone(id: Int) = done("chem.$id")
|
||||||
|
fun markChem(id: Int) { mark("chem.$id") }
|
||||||
|
fun bioDone(id: Int) = done("bio.$id")
|
||||||
|
fun markBio(id: Int) { mark("bio.$id") }
|
||||||
|
fun fyzDone(id: Int) = done("fyz.$id")
|
||||||
|
fun markFyz(id: Int) { mark("fyz.$id") }
|
||||||
|
fun fyz2Done(id: Int) = done("fyz2.$id")
|
||||||
|
fun markFyz2(id: Int) { mark("fyz2.$id") }
|
||||||
|
fun fyz3Done(id: Int) = done("fyz3.$id")
|
||||||
|
fun markFyz3(id: Int) { mark("fyz3.$id") }
|
||||||
|
fun fyz4Done(id: Int) = done("fyz4.$id")
|
||||||
|
fun markFyz4(id: Int) { mark("fyz4.$id") }
|
||||||
|
fun tgDone(id: Int) = done("tg.$id")
|
||||||
|
fun markTg(id: Int) { mark("tg.$id") }
|
||||||
|
fun tg2Done(id: Int) = done("tg2.$id")
|
||||||
|
fun markTg2(id: Int) { mark("tg2.$id") }
|
||||||
|
fun tg3Done(id: Int) = done("tg3.$id")
|
||||||
|
fun markTg3(id: Int) { mark("tg3.$id") }
|
||||||
|
fun tg4Done(id: Int) = done("tg4.$id")
|
||||||
|
fun markTg4(id: Int) { mark("tg4.$id") }
|
||||||
|
fun pgDone(id: Int) = done("pg.$id")
|
||||||
|
fun markPg(id: Int) { mark("pg.$id") }
|
||||||
|
fun pg2Done(id: Int) = done("pg2.$id")
|
||||||
|
fun markPg2(id: Int) { mark("pg2.$id") }
|
||||||
|
fun pg3Done(id: Int) = done("pg3.$id")
|
||||||
|
fun markPg3(id: Int) { mark("pg3.$id") }
|
||||||
|
fun pg4Done(id: Int) = done("pg4.$id")
|
||||||
|
fun markPg4(id: Int) { mark("pg4.$id") }
|
||||||
|
fun swDone(id: Int) = done("sw.$id")
|
||||||
|
fun markSw(id: Int) { mark("sw.$id") }
|
||||||
|
fun sw2Done(id: Int) = done("sw2.$id")
|
||||||
|
fun markSw2(id: Int) { mark("sw2.$id") }
|
||||||
|
fun sw3Done(id: Int) = done("sw3.$id")
|
||||||
|
fun markSw3(id: Int) { mark("sw3.$id") }
|
||||||
|
fun sw4Done(id: Int) = done("sw4.$id")
|
||||||
|
fun markSw4(id: Int) { mark("sw4.$id") }
|
||||||
|
fun csDone(id: Int) = done("cs.$id")
|
||||||
|
fun markCs(id: Int) { mark("cs.$id") }
|
||||||
|
fun cs2Done(id: Int) = done("cs2.$id")
|
||||||
|
fun markCs2(id: Int) { mark("cs2.$id") }
|
||||||
|
fun cs3Done(id: Int) = done("cs3.$id")
|
||||||
|
fun markCs3(id: Int) { mark("cs3.$id") }
|
||||||
|
fun cs4Done(id: Int) = done("cs4.$id")
|
||||||
|
fun markCs4(id: Int) { mark("cs4.$id") }
|
||||||
|
fun mat0Done(id: Int) = done("mat0.$id")
|
||||||
|
fun markMat0(id: Int) { mark("mat0.$id") }
|
||||||
|
fun matDone(id: Int) = done("mat.$id")
|
||||||
|
fun markMat(id: Int) { mark("mat.$id") }
|
||||||
|
fun mat2Done(id: Int) = done("mat2.$id")
|
||||||
|
fun markMat2(id: Int) { mark("mat2.$id") }
|
||||||
|
fun mat3Done(id: Int) = done("mat3.$id")
|
||||||
|
fun markMat3(id: Int) { mark("mat3.$id") }
|
||||||
|
fun mat4Done(id: Int) = done("mat4.$id")
|
||||||
|
fun markMat4(id: Int) { mark("mat4.$id") }
|
||||||
|
|
||||||
|
fun mluvDone(n: Int) = done("mluv.$n")
|
||||||
|
fun markMluv(n: Int) { mark("mluv.$n") }
|
||||||
|
|
||||||
|
fun bookQuiz(id: String) = done("book.$id.quiz")
|
||||||
|
fun markBookQuiz(id: String) { mark("book.$id.quiz") }
|
||||||
|
fun bookPlot(id: String) = done("book.$id.plot")
|
||||||
|
fun markBookPlot(id: String) { mark("book.$id.plot") }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ProgressSum(var doneEx: Int = 0, var totalEx: Int = 0, var doneUnits: Int = 0, var openUnits: Int = 0)
|
||||||
|
|
||||||
|
fun summarize(content: Content, p: ProgressStore): ProgressSum {
|
||||||
|
val sum = ProgressSum()
|
||||||
|
content.german.filter { it.bool("unlocked") }.forEach { u ->
|
||||||
|
val id = u.int("id")
|
||||||
|
val names = u.strs("names")
|
||||||
|
sum.totalEx += names.size
|
||||||
|
sum.openUnits += 1
|
||||||
|
val done = names.indices.count { p.germanDone(id, it + 1) }
|
||||||
|
sum.doneEx += done
|
||||||
|
if (names.isNotEmpty() && done == names.size) sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
content.netLessons.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.netDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.hw.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.hwDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listOf("net2", "net3", "net4", "hw2", "hw3", "hw4").forEach { course ->
|
||||||
|
content.itLessons(course).forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.courseDone(course, l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.on.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.onDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.on2.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.on2Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.on3.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.on3Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.on4.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.on4Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (year in 1..4) {
|
||||||
|
content.enYear(year).forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.enDone(year, l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.deYear(year).forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.deDone(year, l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.lit.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.litDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.lit2.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.lit2Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.lit3.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.lit3Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.lit4.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.lit4Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.chem.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.chemDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.bio.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.bioDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.fyz.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.fyzDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.fyz2.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.fyz2Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.fyz3.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.fyz3Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.fyz4.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.fyz4Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.tg.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.tgDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.tg2.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.tg2Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.tg3.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.tg3Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.tg4.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.tg4Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.pg.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.pgDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.pg2.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.pg2Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.pg3.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.pg3Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.pg4.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.pg4Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.sw.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.swDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.sw2.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.sw2Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.sw3.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.sw3Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.sw4.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.sw4Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.cs.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.csDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.cs2.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.cs2Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.cs3.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.cs3Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.cs4.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.cs4Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.mat0.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.mat0Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.mat.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.matDone(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.mat2.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.mat2Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.mat3.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.mat3Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.mat4.forEach { l ->
|
||||||
|
sum.totalEx += 1
|
||||||
|
sum.openUnits += 1
|
||||||
|
if (p.mat4Done(l.int("id"))) {
|
||||||
|
sum.doneEx += 1
|
||||||
|
sum.doneUnits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum.totalEx += content.mluvnice.size
|
||||||
|
sum.openUnits += 1
|
||||||
|
val md = content.mluvnice.count { p.mluvDone(it.int("id")) }
|
||||||
|
sum.doneEx += md
|
||||||
|
if (md == content.mluvnice.size && content.mluvnice.isNotEmpty()) sum.doneUnits += 1
|
||||||
|
return sum
|
||||||
|
}
|
||||||
196
android/shared/src/org/maturita/maturita/data/Theme.kt
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
package org.maturita.maturita.data
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
enum class ThemeId { Catppuccin, Nord, Dracula, RosePine, Ocean, Gruvbox, Solarized, Everforest, Monokai, OneDark }
|
||||||
|
enum class ColorMode { Dark, Light }
|
||||||
|
enum class UiLang { Cs, En }
|
||||||
|
|
||||||
|
val themeNames = listOf(
|
||||||
|
"Catppuccin", "Nord", "Dracula", "Rose Pine", "Ocean",
|
||||||
|
"Gruvbox", "Solarized", "Everforest", "Monokai", "One Dark",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Palette(
|
||||||
|
val crust: Color,
|
||||||
|
val base: Color,
|
||||||
|
val mantle: Color,
|
||||||
|
val surface0: Color,
|
||||||
|
val surface1: Color,
|
||||||
|
val surface2: Color,
|
||||||
|
val overlay: Color,
|
||||||
|
val text: Color,
|
||||||
|
val subtext: Color,
|
||||||
|
val accent: Color,
|
||||||
|
val accent2: Color,
|
||||||
|
val accent3: Color,
|
||||||
|
val success: Color,
|
||||||
|
val success2: Color,
|
||||||
|
val warning: Color,
|
||||||
|
val error: Color,
|
||||||
|
val onAccent: Color,
|
||||||
|
val node: Color,
|
||||||
|
val lockedBg: Color,
|
||||||
|
val lockedBorder: Color,
|
||||||
|
val rail: Color,
|
||||||
|
val finishDark: Color,
|
||||||
|
val finishLight: Color,
|
||||||
|
val finishStroke: Color,
|
||||||
|
/** Accent that stays readable as text and icon tint on [base]. */
|
||||||
|
val tint: Color,
|
||||||
|
/** Text colour that reads on [tint]. */
|
||||||
|
val onTint: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun hx(v: Long) = Color(0xFF000000L or v)
|
||||||
|
|
||||||
|
private fun luminance(v: Long): Double {
|
||||||
|
fun channel(c: Long): Double {
|
||||||
|
val s = c / 255.0
|
||||||
|
return if (s <= 0.03928) s / 12.92 else Math.pow((s + 0.055) / 1.055, 2.4)
|
||||||
|
}
|
||||||
|
return 0.2126 * channel((v shr 16) and 0xFF) + 0.7152 * channel((v shr 8) and 0xFF) + 0.0722 * channel(v and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contrast(a: Long, b: Long): Double {
|
||||||
|
val la = luminance(a)
|
||||||
|
val lb = luminance(b)
|
||||||
|
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yellow on white is unreadable as link text; fall back to the next accent. */
|
||||||
|
private fun readableTint(c: LongArray): Long {
|
||||||
|
val background = c[1]
|
||||||
|
for (candidate in longArrayOf(c[9], c[10], c[11])) {
|
||||||
|
if (contrast(candidate, background) >= 3.0) return candidate
|
||||||
|
}
|
||||||
|
return c[7]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pal(vararg c: Long): Palette {
|
||||||
|
val tint = readableTint(c)
|
||||||
|
return Palette(
|
||||||
|
hx(c[0]), hx(c[1]), hx(c[2]), hx(c[3]), hx(c[4]), hx(c[5]), hx(c[6]),
|
||||||
|
hx(c[7]), hx(c[8]), hx(c[9]), hx(c[10]), hx(c[11]), hx(c[12]), hx(c[13]),
|
||||||
|
hx(c[14]), hx(c[15]), hx(c[16]), hx(c[17]), hx(c[18]), hx(c[19]), hx(c[20]),
|
||||||
|
hx(c[21]), hx(c[22]), hx(c[23]),
|
||||||
|
tint = hx(tint),
|
||||||
|
onTint = if (luminance(tint) > 0.4) Color(0xFF111111) else Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun themePalette(id: ThemeId, mode: ColorMode): Palette {
|
||||||
|
val light = mode == ColorMode.Light
|
||||||
|
return when (id) {
|
||||||
|
ThemeId.Catppuccin -> if (light) pal(
|
||||||
|
0xf3eee6, 0xfaf7f2, 0xffffff, 0xffffff, 0xf0ebe3, 0xe5dfd4,
|
||||||
|
0x8a8378, 0x1a1a1a, 0x5c574f, 0xffcc00, 0x0071e3, 0x2997ff,
|
||||||
|
0x1f8a4c, 0x1a7f4a, 0xe6a800, 0xd32f2f, 0x1a1a1a,
|
||||||
|
0xffffff, 0xf0ebe3, 0xd5cfc4, 0xe5dfd4, 0xf3eee6, 0xd5cfc4, 0x8a8378,
|
||||||
|
) else pal(
|
||||||
|
0x000000, 0x000000, 0x111111, 0x1d1d1f, 0x2c2c2e, 0x3a3a3c,
|
||||||
|
0x86868b, 0xf5f5f7, 0xa1a1a6, 0xffd60a, 0x2997ff, 0x64d2ff,
|
||||||
|
0x30d158, 0x64d2ff, 0xffd60a, 0xff453a, 0x1d1d1f,
|
||||||
|
0x1d1d1f, 0x111111, 0x3a3a3c, 0x2c2c2e, 0x000000, 0x3a3a3c, 0x86868b,
|
||||||
|
)
|
||||||
|
ThemeId.Nord -> if (light) pal(
|
||||||
|
0xd8dee9, 0xeceff4, 0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xb0b8c8,
|
||||||
|
0x7b88a1, 0x2e3440, 0x4c566a, 0x5e81ac, 0x81a1c1, 0x5e81ac,
|
||||||
|
0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0xeceff4,
|
||||||
|
0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xd8dee9, 0xd8dee9, 0xc7ceda, 0x7b88a1,
|
||||||
|
) else pal(
|
||||||
|
0x2e3440, 0x3b4252, 0x2e3440, 0x434c5e, 0x4c566a, 0x616e88,
|
||||||
|
0x7b88a1, 0xeceff4, 0xd8dee9, 0x88c0d0, 0x81a1c1, 0x5e81ac,
|
||||||
|
0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0x2e3440,
|
||||||
|
0x434c5e, 0x3b4252, 0x4c566a, 0x2e3440, 0x2e3440, 0x434c5e, 0x4c566a,
|
||||||
|
)
|
||||||
|
ThemeId.Dracula -> if (light) pal(
|
||||||
|
0xd5d7e0, 0xf8f8f2, 0xefefea, 0xe2e2d8, 0xd0d0c4, 0xbfbfb2,
|
||||||
|
0x9a9a8c, 0x282a36, 0x44475a, 0x7c3aed, 0xd946a6, 0x0891b2,
|
||||||
|
0x16a34a, 0x0e7490, 0xca8a04, 0xdc2626, 0xf8f8f2,
|
||||||
|
0xefefea, 0xe2e2d8, 0xd0d0c4, 0xe2e2d8, 0xd5d7e0, 0xd0d0c4, 0x9a9a8c,
|
||||||
|
) else pal(
|
||||||
|
0x191a21, 0x282a36, 0x21222c, 0x44475a, 0x6272a4, 0x707eb0,
|
||||||
|
0x6272a4, 0xf8f8f2, 0xbfbfb2, 0xbd93f9, 0xff79c6, 0x8be9fd,
|
||||||
|
0x50fa7b, 0x8be9fd, 0xf1fa8c, 0xff5555, 0x282a36,
|
||||||
|
0x383a4a, 0x2f3240, 0x44475a, 0x21222c, 0x191a21, 0x383a4a, 0x6272a4,
|
||||||
|
)
|
||||||
|
ThemeId.RosePine -> if (light) pal(
|
||||||
|
0xf2e9e1, 0xfaf4ed, 0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xcecacd,
|
||||||
|
0x9893a5, 0x575279, 0x797593, 0x907aa9, 0xd7827e, 0x56949f,
|
||||||
|
0x286983, 0x56949f, 0xea9d34, 0xb4637a, 0xfaf4ed,
|
||||||
|
0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xf2e9e1, 0xf2e9e1, 0xdfdad9, 0x9893a5,
|
||||||
|
) else pal(
|
||||||
|
0x191724, 0x1f1d2e, 0x1f1d2e, 0x26233a, 0x403d52, 0x524f67,
|
||||||
|
0x6e6a86, 0xe0def4, 0x908caa, 0xc4a7e7, 0xebbcba, 0x9ccfd8,
|
||||||
|
0x31748f, 0x9ccfd8, 0xf6c177, 0xeb6f92, 0x191724,
|
||||||
|
0x26233a, 0x21202e, 0x403d52, 0x191724, 0x191724, 0x26233a, 0x524f67,
|
||||||
|
)
|
||||||
|
ThemeId.Ocean -> if (light) pal(
|
||||||
|
0xd6dae8, 0xe1e2ec, 0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0x9aa0b5,
|
||||||
|
0x7a8199, 0x2e3446, 0x4a5168, 0x2e7de9, 0x7847bd, 0x007197,
|
||||||
|
0x387068, 0x007197, 0x8c6c3e, 0xc64343, 0xe1e2ec,
|
||||||
|
0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0xc8c9d6, 0xd6dae8, 0xb4b6c5, 0x7a8199,
|
||||||
|
) else pal(
|
||||||
|
0x16161e, 0x1a1b26, 0x16161e, 0x24283b, 0x414868, 0x565f89,
|
||||||
|
0x565f89, 0xc0caf5, 0xa9b1d6, 0x7aa2f7, 0xbb9af7, 0x7dcfff,
|
||||||
|
0x9ece6a, 0x73daca, 0xe0af68, 0xf7768e, 0x1a1b26,
|
||||||
|
0x24283b, 0x1f2335, 0x3b4261, 0x16161e, 0x16161e, 0x24283b, 0x414868,
|
||||||
|
)
|
||||||
|
ThemeId.Gruvbox -> if (light) pal(
|
||||||
|
0xebdbb2, 0xfbf1c7, 0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xbdae93,
|
||||||
|
0x928374, 0x3c3836, 0x504945, 0xd65d0e, 0xb16286, 0x076678,
|
||||||
|
0x79740e, 0x427b58, 0xb57614, 0x9d0006, 0xfbf1c7,
|
||||||
|
0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xebdbb2, 0xebdbb2, 0xd5c4a1, 0x928374,
|
||||||
|
) else pal(
|
||||||
|
0x1d2021, 0x282828, 0x1d2021, 0x3c3836, 0x504945, 0x665c54,
|
||||||
|
0x928374, 0xebdbb2, 0xa89984, 0xfe8019, 0xd3869b, 0x83a598,
|
||||||
|
0xb8bb26, 0x8ec07c, 0xfabd2f, 0xfb4934, 0x1d2021,
|
||||||
|
0x3c3836, 0x32302f, 0x504945, 0x1d2021, 0x1d2021, 0x3c3836, 0x504945,
|
||||||
|
)
|
||||||
|
ThemeId.Solarized -> if (light) pal(
|
||||||
|
0xeee8d5, 0xfdf6e3, 0xeee8d5, 0xeee8d5, 0xe2dbc6, 0xc9c0a6,
|
||||||
|
0x657b83, 0x586e75, 0x657b83, 0x268bd2, 0x6c71c4, 0x2aa198,
|
||||||
|
0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0xfdf6e3,
|
||||||
|
0xeee8d5, 0xe6dfc8, 0x93a1a1, 0xeee8d5, 0xeee8d5, 0x93a1a1, 0x586e75,
|
||||||
|
) else pal(
|
||||||
|
0x002b36, 0x073642, 0x002b36, 0x586e75, 0x657b83, 0x839496,
|
||||||
|
0x93a1a1, 0xfdf6e3, 0x93a1a1, 0x268bd2, 0x6c71c4, 0x2aa198,
|
||||||
|
0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0x002b36,
|
||||||
|
0x073642, 0x002b36, 0x586e75, 0x002b36, 0x002b36, 0x073642, 0x586e75,
|
||||||
|
)
|
||||||
|
ThemeId.Everforest -> if (light) pal(
|
||||||
|
0xe6e2cc, 0xfdf6e3, 0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xa6b0a0,
|
||||||
|
0x939f91, 0x5c6a72, 0x829181, 0x8da101, 0xdf69ba, 0x35a77c,
|
||||||
|
0x8da101, 0x35a77c, 0xdfa000, 0xf85552, 0xfdf6e3,
|
||||||
|
0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xe6e2cc, 0xe6e2cc, 0xe0dcc7, 0x939f91,
|
||||||
|
) else pal(
|
||||||
|
0x1e2326, 0x272e33, 0x2d353b, 0x374145, 0x4f5b58, 0x7a8478,
|
||||||
|
0x859289, 0xd3c6aa, 0xa7c080, 0xa7c080, 0xd699b6, 0x7fbbb3,
|
||||||
|
0xa7c080, 0x83c092, 0xdbbc7f, 0xe67e80, 0x272e33,
|
||||||
|
0x374145, 0x2d353b, 0x4f5b58, 0x1e2326, 0x1e2326, 0x374145, 0x4f5b58,
|
||||||
|
)
|
||||||
|
ThemeId.Monokai -> if (light) pal(
|
||||||
|
0xe6e3d4, 0xfaf8f0, 0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xb8b49e,
|
||||||
|
0x8a8674, 0x2d2a2e, 0x5b5854, 0xe14775, 0x7c5cbf, 0x1c7d9b,
|
||||||
|
0x5b8c2a, 0x1c7d9b, 0xb08900, 0xe14775, 0xfaf8f0,
|
||||||
|
0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xe6e3d4, 0xe6e3d4, 0xd6d2c0, 0x8a8674,
|
||||||
|
) else pal(
|
||||||
|
0x1d1e19, 0x272822, 0x1e1f1a, 0x3e3d32, 0x49483e, 0x75715e,
|
||||||
|
0x75715e, 0xf8f8f2, 0xa59f85, 0xf92672, 0xae81ff, 0x66d9ef,
|
||||||
|
0xa6e22e, 0xa1efe4, 0xe6db74, 0xf92672, 0x272822,
|
||||||
|
0x3e3d32, 0x2e2f29, 0x49483e, 0x1d1e19, 0x1d1e19, 0x3e3d32, 0x49483e,
|
||||||
|
)
|
||||||
|
ThemeId.OneDark -> if (light) pal(
|
||||||
|
0xe5e5e6, 0xfafafa, 0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7,
|
||||||
|
0xa0a1a7, 0x383a42, 0x696c77, 0xa626a4, 0x4078f2, 0x0184bc,
|
||||||
|
0x50a14f, 0x0184bc, 0xc18401, 0xe45649, 0xfafafa,
|
||||||
|
0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xe5e5e6, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7,
|
||||||
|
) else pal(
|
||||||
|
0x21252b, 0x282c34, 0x21252b, 0x3b4048, 0x4b5263, 0x5c6370,
|
||||||
|
0x5c6370, 0xabb2bf, 0x7f848e, 0xc678dd, 0x61afef, 0x56b6c2,
|
||||||
|
0x98c379, 0x56b6c2, 0xe5c07b, 0xe06c75, 0x282c34,
|
||||||
|
0x3b4048, 0x2c313a, 0x4b5263, 0x21252b, 0x21252b, 0x3b4048, 0x4b5263,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
package org.maturita.maturita.platform
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the shared app needs from the system it runs on. Android and the
|
||||||
|
* desktop (Linux, Windows) each provide one; the screens never touch platform
|
||||||
|
* APIs directly.
|
||||||
|
*/
|
||||||
|
interface Platform {
|
||||||
|
/** Source commit baked into the build, or "dev" for local builds. */
|
||||||
|
val commit: String
|
||||||
|
val versionName: String
|
||||||
|
/** Text key of the welcome card blurb, which names the device. */
|
||||||
|
val welcomeKey: String
|
||||||
|
val prefs: Prefs
|
||||||
|
val updater: AppUpdater
|
||||||
|
|
||||||
|
/** A speech engine for listening exercises, or null when there is none. */
|
||||||
|
fun speaker(): Speaker?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles and runs a C# program. [cb] runs on the UI thread.
|
||||||
|
* Status is ok, fail, timeout or unavailable.
|
||||||
|
*/
|
||||||
|
fun runCSharp(source: String, stdin: String, cb: (CSharpRun) -> Unit) {
|
||||||
|
cb(CSharpRun("unavailable", ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CSharpRun(val status: String, val output: String)
|
||||||
|
|
||||||
|
/** The subset of Android's SharedPreferences the app uses. */
|
||||||
|
interface Prefs {
|
||||||
|
fun getBoolean(key: String, fallback: Boolean): Boolean
|
||||||
|
fun getInt(key: String, fallback: Int): Int
|
||||||
|
fun getString(key: String, fallback: String?): String?
|
||||||
|
fun edit(): Editor
|
||||||
|
|
||||||
|
interface Editor {
|
||||||
|
fun putBoolean(key: String, value: Boolean): Editor
|
||||||
|
fun putInt(key: String, value: Int): Editor
|
||||||
|
fun putString(key: String, value: String): Editor
|
||||||
|
fun apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UpdateState(
|
||||||
|
val status: String = "idle",
|
||||||
|
val remote: String? = null,
|
||||||
|
val messageKey: String = "update_current",
|
||||||
|
val messageArg: String? = null,
|
||||||
|
val canInstall: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Callbacks always arrive on the UI thread. */
|
||||||
|
interface AppUpdater {
|
||||||
|
fun check(interactive: Boolean, cb: (UpdateState) -> Unit)
|
||||||
|
fun install(cb: (UpdateState) -> Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Speaker {
|
||||||
|
/**
|
||||||
|
* Reads [text] aloud. [onRange] gets the offset of the word being spoken
|
||||||
|
* where the engine reports it; [onEnd] gets false when speaking failed.
|
||||||
|
* Both run on the UI thread.
|
||||||
|
*/
|
||||||
|
fun speak(
|
||||||
|
text: String,
|
||||||
|
locale: Locale,
|
||||||
|
rate: Float,
|
||||||
|
volume: Float,
|
||||||
|
onRange: (Int) -> Unit,
|
||||||
|
onEnd: (Boolean) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun stop()
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
303
android/shared/src/org/maturita/maturita/ui/CodeLesson.kt
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
package org.maturita.maturita.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Cancel
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Code
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.input.OffsetMapping
|
||||||
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.max
|
||||||
|
import androidx.compose.ui.unit.min
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.maturita.maturita.AppViewModel
|
||||||
|
import org.maturita.maturita.data.J
|
||||||
|
|
||||||
|
/** Explanation on one side, the program and its output on the other. */
|
||||||
|
@Composable
|
||||||
|
fun CodeLessonScreen(vm: AppViewModel, lesson: J, onSuccess: () -> Unit) {
|
||||||
|
val key = lesson.str("key")
|
||||||
|
val starter = lesson.str("code")
|
||||||
|
var code by remember(key) { mutableStateOf(starter) }
|
||||||
|
var stdin by remember(key) { mutableStateOf("") }
|
||||||
|
var output by remember(key) { mutableStateOf("") }
|
||||||
|
var status by remember(key) { mutableStateOf("") }
|
||||||
|
val running = status == "running"
|
||||||
|
val needsInput = "ReadLine" in code
|
||||||
|
|
||||||
|
fun run() {
|
||||||
|
if (running) return
|
||||||
|
status = "running"
|
||||||
|
output = vm.tr("cs_running")
|
||||||
|
vm.platform.runCSharp(code, if (needsInput) stdin else "") { result ->
|
||||||
|
status = result.status
|
||||||
|
output = when (result.status) {
|
||||||
|
"unavailable" -> vm.tr("cs_unavailable")
|
||||||
|
"timeout" -> (result.output + "\n\n" + vm.tr("cs_timeout")).trim()
|
||||||
|
else -> result.output.ifEmpty { vm.tr("cs_empty") }
|
||||||
|
}
|
||||||
|
if (result.status == "ok") onSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
code = starter
|
||||||
|
output = ""
|
||||||
|
status = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
DetailScaffold(vm, vm.tr(lesson.str("titleKey")), vm.tr(lesson.str("subKey")), maxContentWidth = 1640.dp) { inner ->
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize().padding(inner)) {
|
||||||
|
val width = maxWidth
|
||||||
|
val height = maxHeight
|
||||||
|
if (width >= 900.dp) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.width(min(560.dp, width * 0.4f)).fillMaxHeight().verticalScroll(rememberScrollState())) {
|
||||||
|
Explanation(lesson)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
EditorCard(vm, code, { code = it }, running, ::run, ::reset, compact = false, Modifier.weight(1f))
|
||||||
|
if (needsInput) InputField(vm, stdin) { stdin = it }
|
||||||
|
OutputCard(vm, output, status, Modifier.height(max(180.dp, height * 0.32f)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Explanation(lesson)
|
||||||
|
EditorCard(vm, code, { code = it }, running, ::run, ::reset, compact = true, Modifier.height(320.dp))
|
||||||
|
if (needsInput) InputField(vm, stdin) { stdin = it }
|
||||||
|
OutputCard(vm, output, status, Modifier.height(220.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Explanation(lesson: J) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(28.dp)) {
|
||||||
|
lesson.arr("slides").forEach { SlideContent(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditorCard(
|
||||||
|
vm: AppViewModel,
|
||||||
|
code: String,
|
||||||
|
onCode: (String) -> Unit,
|
||||||
|
running: Boolean,
|
||||||
|
onRun: () -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
compact: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||||
|
val highlight = remember(dark) { CSharpHighlight(dark) }
|
||||||
|
Surface(modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Code, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
Text("Program.cs", style = MaterialTheme.typography.titleSmall.copy(fontFamily = FontFamily.Monospace))
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (compact) {
|
||||||
|
IconButton(onClick = onReset, enabled = !running) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = vm.tr("cs_reset"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = onReset, enabled = !running) {
|
||||||
|
Icon(Icons.Filled.Refresh, null, Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(vm.tr("cs_reset"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(onClick = onRun, enabled = !running) {
|
||||||
|
Icon(Icons.Filled.PlayArrow, null, Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(vm.tr("cs_run"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
BasicTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = onCode,
|
||||||
|
textStyle = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
visualTransformation = highlight,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InputField(vm: AppViewModel, stdin: String, onStdin: (String) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(vm.tr("cs_stdin"), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = stdin,
|
||||||
|
onValueChange = onStdin,
|
||||||
|
textStyle = codeStyle(),
|
||||||
|
minLines = 1,
|
||||||
|
maxLines = 4,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OutputCard(vm: AppViewModel, output: String, status: String, modifier: Modifier) {
|
||||||
|
val muted = output.isEmpty() || status == "running"
|
||||||
|
Surface(modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().height(48.dp).padding(horizontal = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Terminal, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(vm.tr("cs_output"), style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
StatusBadge(vm, status)
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
Text(
|
||||||
|
output.ifEmpty { vm.tr("cs_empty") },
|
||||||
|
style = codeStyle(),
|
||||||
|
color = if (muted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusBadge(vm: AppViewModel, status: String) {
|
||||||
|
when (status) {
|
||||||
|
"running" -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
|
"ok" -> Badge(vm.tr("cs_done"), Icons.Filled.CheckCircle, MaterialTheme.colorScheme.primary)
|
||||||
|
"fail", "timeout" -> Badge(vm.tr("cs_failed"), Icons.Filled.Cancel, MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Badge(text: String, icon: androidx.compose.ui.graphics.vector.ImageVector, color: Color) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(icon, null, tint = color, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.labelLarge, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun codeStyle() = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 15.sp, lineHeight = 22.sp)
|
||||||
|
|
||||||
|
/** Colours C# keywords, types, numbers, strings and comments without changing the text. */
|
||||||
|
private class CSharpHighlight(dark: Boolean) : VisualTransformation {
|
||||||
|
private val palette = listOf(
|
||||||
|
Color(if (dark) 0xFFFF7AB2 else 0xFF9B2393),
|
||||||
|
Color(if (dark) 0xFF5DD8FF else 0xFF0B4F79),
|
||||||
|
Color(if (dark) 0xFFD9C97C else 0xFF1C00CF),
|
||||||
|
Color(if (dark) 0xFFFC6A5D else 0xFFC41A16),
|
||||||
|
Color(if (dark) 0xFF7F8C98 else 0xFF5D6C79),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun filter(text: AnnotatedString): TransformedText =
|
||||||
|
TransformedText(highlight(text.text), OffsetMapping.Identity)
|
||||||
|
|
||||||
|
private fun highlight(code: String): AnnotatedString {
|
||||||
|
val kind = IntArray(code.length) { -1 }
|
||||||
|
RULES.forEachIndexed { index, rule ->
|
||||||
|
rule.findAll(code).forEach { m -> for (i in m.range) kind[i] = index }
|
||||||
|
}
|
||||||
|
return buildAnnotatedString {
|
||||||
|
var start = 0
|
||||||
|
while (start < code.length) {
|
||||||
|
var end = start
|
||||||
|
while (end < code.length && kind[end] == kind[start]) end++
|
||||||
|
val k = kind[start]
|
||||||
|
if (k >= 0) {
|
||||||
|
pushStyle(SpanStyle(color = palette[k]))
|
||||||
|
append(code, start, end)
|
||||||
|
pop()
|
||||||
|
} else {
|
||||||
|
append(code, start, end)
|
||||||
|
}
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEYWORDS = listOf(
|
||||||
|
"abstract", "as", "async", "await", "base", "bool", "break", "case", "catch", "char", "class",
|
||||||
|
"const", "continue", "decimal", "default", "do", "double", "else", "enum", "false", "finally",
|
||||||
|
"float", "for", "foreach", "get", "if", "in", "int", "interface", "internal", "is", "long",
|
||||||
|
"namespace", "new", "null", "object", "out", "override", "private", "protected", "public",
|
||||||
|
"readonly", "record", "ref", "return", "sealed", "set", "static", "string", "struct", "switch",
|
||||||
|
"this", "throw", "true", "try", "using", "var", "virtual", "void", "while",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Later rules win, so strings and comments override keywords inside them. */
|
||||||
|
private val RULES = listOf(
|
||||||
|
Regex("\\b(?:" + KEYWORDS.joinToString("|") + ")\\b"),
|
||||||
|
Regex("\\b(?:Console|Math|List|Dictionary|File|Task|Exception|Func|Action|String|DateTime|Random)\\b"),
|
||||||
|
Regex("\\b\\d+(?:\\.\\d+)?\\b"),
|
||||||
|
Regex("\\$?@?\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])'"),
|
||||||
|
Regex("//[^\\n]*|/\\*[\\s\\S]*?\\*/"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
627
android/shared/src/org/maturita/maturita/ui/Components.kt
Normal file
|
|
@ -0,0 +1,627 @@
|
||||||
|
package org.maturita.maturita.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
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.BoxWithConstraints
|
||||||
|
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.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
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.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
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.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Cancel
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LargeTopAppBar
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SuggestionChip
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontSynthesis
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.maturita.maturita.AppViewModel
|
||||||
|
import org.maturita.maturita.Route
|
||||||
|
|
||||||
|
/** Bold is capped at Medium: synthetic bold doubled glyphs on some devices. */
|
||||||
|
fun appTextStyle(
|
||||||
|
fontSize: TextUnit,
|
||||||
|
fontWeight: FontWeight = FontWeight.Normal,
|
||||||
|
textAlign: TextAlign = TextAlign.Unspecified,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
): TextStyle {
|
||||||
|
val weight = if (fontWeight >= FontWeight.Bold) FontWeight.Medium else fontWeight
|
||||||
|
return TextStyle(
|
||||||
|
color = color,
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontWeight = weight,
|
||||||
|
fontFamily = FontFamily.SansSerif,
|
||||||
|
fontSynthesis = FontSynthesis.None,
|
||||||
|
lineHeight = fontSize * 1.35f,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
textAlign = textAlign,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appTypography(): Typography = Typography(
|
||||||
|
displaySmall = appTextStyle(34.sp, FontWeight.Medium),
|
||||||
|
headlineLarge = appTextStyle(30.sp, FontWeight.Medium),
|
||||||
|
headlineMedium = appTextStyle(26.sp, FontWeight.Medium),
|
||||||
|
headlineSmall = appTextStyle(22.sp, FontWeight.Medium),
|
||||||
|
titleLarge = appTextStyle(20.sp, FontWeight.Medium),
|
||||||
|
titleMedium = appTextStyle(16.sp, FontWeight.Medium),
|
||||||
|
titleSmall = appTextStyle(14.sp, FontWeight.Medium),
|
||||||
|
bodyLarge = appTextStyle(16.sp),
|
||||||
|
bodyMedium = appTextStyle(14.sp),
|
||||||
|
bodySmall = appTextStyle(12.sp),
|
||||||
|
labelLarge = appTextStyle(14.sp, FontWeight.Medium),
|
||||||
|
labelMedium = appTextStyle(12.sp, FontWeight.Medium),
|
||||||
|
labelSmall = appTextStyle(11.sp, FontWeight.Medium),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chrome
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppNavBar(vm: AppViewModel) {
|
||||||
|
val root = vm.root
|
||||||
|
NavigationBar {
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = root == Route.Subjects,
|
||||||
|
onClick = { vm.go(Route.Subjects) },
|
||||||
|
icon = { SubjectsIcon(navIconColor(root == Route.Subjects)) },
|
||||||
|
label = { Text(vm.tr("subjects_title"), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = root == Route.Stats,
|
||||||
|
onClick = { vm.go(Route.Stats) },
|
||||||
|
icon = { StatsIcon(navIconColor(root == Route.Stats), 22.dp) },
|
||||||
|
label = { Text(vm.tr("stats"), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = root == Route.Settings,
|
||||||
|
onClick = { vm.go(Route.Settings) },
|
||||||
|
icon = { SettingsIcon(navIconColor(root == Route.Settings), 22.dp) },
|
||||||
|
label = { Text(vm.tr("settings"), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun navIconColor(selected: Boolean): Color =
|
||||||
|
if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
|
/** Lists and exercises stop growing here so they keep their shape in wide desktop windows. */
|
||||||
|
val ContentMaxWidth = 880.dp
|
||||||
|
val ActionMaxWidth = 560.dp
|
||||||
|
|
||||||
|
/** Caps the width of [content] and centres it; phones are narrower than the cap anyway. */
|
||||||
|
@Composable
|
||||||
|
fun CenteredColumn(maxWidth: Dp = ContentMaxWidth, content: @Composable () -> Unit) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||||
|
Box(Modifier.widthIn(max = maxWidth).fillMaxSize()) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab root: large collapsing title on phones, a compact one in wide windows. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RootScaffold(
|
||||||
|
vm: AppViewModel,
|
||||||
|
title: String,
|
||||||
|
showSearch: Boolean = true,
|
||||||
|
content: LazyListScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
BoxWithConstraints {
|
||||||
|
val wide = maxWidth >= 600.dp
|
||||||
|
val scroll = if (wide) {
|
||||||
|
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
|
} else {
|
||||||
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
|
}
|
||||||
|
val actions: @Composable RowScope.() -> Unit = {
|
||||||
|
if (showSearch) {
|
||||||
|
IconButton(onClick = { vm.searchOpen = true }) {
|
||||||
|
Icon(Icons.Filled.Search, contentDescription = vm.tr("search_placeholder"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
if (wide) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineSmall) },
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = { AppNavBar(vm) },
|
||||||
|
) { inner ->
|
||||||
|
CenteredColumn {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
top = inner.calculateTopPadding(),
|
||||||
|
bottom = inner.calculateBottomPadding() + 16.dp,
|
||||||
|
),
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pushed screen: back arrow, title with optional subtitle, optional bottom action bar. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DetailScaffold(
|
||||||
|
vm: AppViewModel,
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
bottomBar: (@Composable () -> Unit)? = null,
|
||||||
|
maxContentWidth: Dp = ContentMaxWidth,
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleLarge)
|
||||||
|
if (!subtitle.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { vm.back() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = vm.tr("back"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = { bottomBar?.invoke() },
|
||||||
|
) { inner ->
|
||||||
|
CenteredColumn(maxContentWidth) { content(inner) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feedback line plus the primary action, anchored at the bottom of an exercise. */
|
||||||
|
@Composable
|
||||||
|
fun ActionBar(
|
||||||
|
label: String,
|
||||||
|
feedback: String = "",
|
||||||
|
kind: String = "",
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 2.dp) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentWidth(Alignment.CenterHorizontally)
|
||||||
|
.widthIn(max = ActionMaxWidth)
|
||||||
|
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = feedback.isNotEmpty(),
|
||||||
|
enter = fadeIn(tween(180)) + expandVertically(),
|
||||||
|
exit = fadeOut(tween(120)) + shrinkVertically(),
|
||||||
|
) {
|
||||||
|
FeedbackLine(feedback, kind, animate = false)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
leading?.invoke()
|
||||||
|
Button(onClick = onClick, enabled = enabled, modifier = Modifier.weight(1f).height(50.dp)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lists
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number or short label in a tinted square, so rows line up whatever the label's width. */
|
||||||
|
@Composable
|
||||||
|
fun LeadingBadge(text: String) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(text, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LeadingIcon(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One navigable row: done/pending/locked state on the left, chevron on the right. */
|
||||||
|
@Composable
|
||||||
|
fun NavRow(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
done: Boolean = false,
|
||||||
|
locked: Boolean = false,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
progress: Pair<Int, Int>? = null,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
val enabled = !locked && onClick != null
|
||||||
|
val textColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val rowModifier = if (enabled) Modifier.clickable(onClick = onClick!!) else Modifier
|
||||||
|
val shownSubtitle = subtitle?.takeIf { it.isNotBlank() }
|
||||||
|
val shownProgress = progress?.takeIf { it.second > 0 }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(title, color = textColor) },
|
||||||
|
supportingContent = if (shownSubtitle == null && shownProgress == null) null else {
|
||||||
|
{
|
||||||
|
Column {
|
||||||
|
shownSubtitle?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
shownProgress?.let { (doneCount, total) ->
|
||||||
|
Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SmoothProgress(doneCount / total.toFloat(), Modifier.weight(1f).height(6.dp))
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
"$doneCount / $total",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
when {
|
||||||
|
leading != null -> leading()
|
||||||
|
locked -> Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.outline)
|
||||||
|
done -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
else -> Icon(Icons.Filled.RadioButtonUnchecked, null, tint = MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = if (enabled) {
|
||||||
|
{ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = MaterialTheme.colorScheme.outline) }
|
||||||
|
} else null,
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = rowModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exercise widgets
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PrimaryButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
Button(onClick = onClick, modifier = modifier) { Text(label) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PillButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
FilledTonalButton(onClick = onClick, modifier = modifier) { Text(label) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SmoothProgress(
|
||||||
|
progress: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
color: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
|
||||||
|
) {
|
||||||
|
val animated by animateFloatAsState(
|
||||||
|
targetValue = progress.coerceIn(0f, 1f),
|
||||||
|
animationSpec = tween(durationMillis = 380),
|
||||||
|
label = "progress",
|
||||||
|
)
|
||||||
|
Box(modifier.heightIn(min = 4.dp).clip(CircleShape).background(trackColor)) {
|
||||||
|
if (animated > 0f) {
|
||||||
|
Box(Modifier.fillMaxHeight().fillMaxWidth(animated).clip(CircleShape).background(color))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Circular progress with a label in the middle. */
|
||||||
|
@Composable
|
||||||
|
fun ProgressRing(progress: Float, modifier: Modifier = Modifier, stroke: Dp = 10.dp, label: @Composable () -> Unit) {
|
||||||
|
val animated by animateFloatAsState(progress.coerceIn(0f, 1f), tween(durationMillis = 380), label = "ring")
|
||||||
|
val track = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
||||||
|
val fill = MaterialTheme.colorScheme.primary
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) {
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
val width = stroke.toPx()
|
||||||
|
val inset = width / 2
|
||||||
|
val arcSize = Size(size.width - width, size.height - width)
|
||||||
|
drawArc(track, 0f, 360f, false, Offset(inset, inset), arcSize, style = Stroke(width))
|
||||||
|
if (animated > 0f) {
|
||||||
|
drawArc(fill, -90f, 360f * animated, false, Offset(inset, inset), arcSize, style = Stroke(width, cap = StrokeCap.Round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
label()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FeedbackLine(text: String, kind: String, animate: Boolean = true) {
|
||||||
|
val color = when (kind) {
|
||||||
|
"ok" -> MaterialTheme.colorScheme.primary
|
||||||
|
"warn" -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.error
|
||||||
|
}
|
||||||
|
val icon = when (kind) {
|
||||||
|
"ok" -> Icons.Filled.CheckCircle
|
||||||
|
"warn" -> Icons.Filled.Warning
|
||||||
|
else -> Icons.Filled.Cancel
|
||||||
|
}
|
||||||
|
if (!animate) {
|
||||||
|
if (text.isEmpty()) return
|
||||||
|
FeedbackRow(text, color, icon)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = text.isNotEmpty(),
|
||||||
|
enter = fadeIn(tween(180)) + expandVertically(),
|
||||||
|
exit = fadeOut(tween(120)) + shrinkVertically(),
|
||||||
|
) {
|
||||||
|
FeedbackRow(text, color, icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeedbackRow(text: String, color: Color, icon: ImageVector) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(icon, null, tint = color, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(text, color = color, style = MaterialTheme.typography.titleSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Meaning(text: String, visible: Boolean) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible && text.isNotEmpty(),
|
||||||
|
enter = fadeIn(tween(180)),
|
||||||
|
exit = fadeOut(tween(120)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Hint(text: String) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Prompt(text: String) {
|
||||||
|
Text(text, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp, bottom = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WordField(
|
||||||
|
value: String,
|
||||||
|
onValue: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
mark: Boolean? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
numeric: Boolean = false,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValue,
|
||||||
|
placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder) }) else null,
|
||||||
|
singleLine = true,
|
||||||
|
isError = mark == false,
|
||||||
|
trailingIcon = when (mark) {
|
||||||
|
true -> ({ Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) })
|
||||||
|
false -> ({ Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error) })
|
||||||
|
null -> null
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = if (numeric) KeyboardType.Decimal else KeyboardType.Text,
|
||||||
|
),
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tappable word in a bank or a built sentence. */
|
||||||
|
@Composable
|
||||||
|
fun Chip(text: String, onClick: () -> Unit) {
|
||||||
|
SuggestionChip(onClick = onClick, label = { Text(text) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SegChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiple-choice answer with radio state and, after checking, a correct/wrong mark. */
|
||||||
|
@Composable
|
||||||
|
fun OptionRow(text: String, selected: Boolean, mark: Boolean?, enabled: Boolean = true, onClick: () -> Unit) {
|
||||||
|
val target = when (mark) {
|
||||||
|
true -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
false -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
null -> if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
val container by animateColorAsState(target, tween(180), label = "option")
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
color = container,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
when (mark) {
|
||||||
|
true -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
false -> Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error)
|
||||||
|
null -> RadioButton(selected = selected, onClick = null)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop zone for word ordering; visible even without words. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun WordSlot(empty: Boolean, content: @Composable () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
FlowRow(
|
||||||
|
Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (empty) Text("…", color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding(8.dp))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom sheet with the word bank for combo/dialogue blanks. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WordPicker(title: String, pool: List<String>, onPick: (String?) -> Unit) {
|
||||||
|
ModalBottomSheet(onDismissRequest = { onPick(null) }) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
|
items(pool.size) { i ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(pool[i]) },
|
||||||
|
modifier = Modifier.clickable { onPick(pool[i]) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1658
android/shared/src/org/maturita/maturita/ui/Exercises.kt
Normal file
296
android/shared/src/org/maturita/maturita/ui/Icons.kt
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
package org.maturita.maturita.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathFillType
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.maturita.maturita.data.Palette
|
||||||
|
@Composable
|
||||||
|
fun DrawnIcon(iconSize: Dp, modifier: Modifier = Modifier, onDraw: androidx.compose.ui.graphics.drawscope.DrawScope.() -> Unit) {
|
||||||
|
Canvas(modifier.size(iconSize)) { onDraw() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackIcon(color: Color, iconSize: Dp = 16.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val p = Path().apply {
|
||||||
|
moveTo(size.width * 0.68f, size.height * 0.18f)
|
||||||
|
lineTo(size.width * 0.28f, size.height * 0.5f)
|
||||||
|
lineTo(size.width * 0.68f, size.height * 0.82f)
|
||||||
|
}
|
||||||
|
drawPath(p, color, style = Stroke(width = size.minDimension * 0.16f, cap = StrokeCap.Round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchIcon(color: Color, iconSize: Dp = 18.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val r = size.minDimension * 0.28f
|
||||||
|
drawCircle(color, r, Offset(size.width * 0.42f, size.height * 0.42f), style = Stroke(size.minDimension * 0.12f))
|
||||||
|
drawLine(color, Offset(size.width * 0.62f, size.height * 0.62f), Offset(size.width * 0.82f, size.height * 0.82f), size.minDimension * 0.12f, StrokeCap.Round)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatsIcon(color: Color, iconSize: Dp = 18.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val w = size.minDimension * 0.16f
|
||||||
|
drawRoundRect(color, Offset(size.width * 0.18f, size.height * 0.48f), Size(w, size.height * 0.36f), androidx.compose.ui.geometry.CornerRadius(w))
|
||||||
|
drawRoundRect(color, Offset(size.width * 0.42f, size.height * 0.22f), Size(w, size.height * 0.62f), androidx.compose.ui.geometry.CornerRadius(w))
|
||||||
|
drawRoundRect(color, Offset(size.width * 0.66f, size.height * 0.36f), Size(w, size.height * 0.48f), androidx.compose.ui.geometry.CornerRadius(w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SubjectsIcon(color: Color, iconSize: Dp = 22.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val gap = size.minDimension * 0.08f
|
||||||
|
val cell = (size.minDimension - gap) / 2f
|
||||||
|
val r = androidx.compose.ui.geometry.CornerRadius(cell * 0.28f)
|
||||||
|
for (iy in 0 until 2) for (ix in 0 until 2) {
|
||||||
|
drawRoundRect(color, Offset(ix * (cell + gap), iy * (cell + gap)), Size(cell, cell), r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val cx = size.width / 2f
|
||||||
|
val cy = size.height / 2f
|
||||||
|
val s = size.minDimension
|
||||||
|
val rOuter = s * 0.48f
|
||||||
|
val rInner = s * 0.34f
|
||||||
|
val rHole = s * 0.16f
|
||||||
|
val teeth = 8
|
||||||
|
val toothHalf = (Math.PI / teeth).toFloat()
|
||||||
|
val gear = Path().apply {
|
||||||
|
fillType = PathFillType.EvenOdd
|
||||||
|
for (i in 0 until teeth) {
|
||||||
|
val a = (-Math.PI / 2.0 + 2.0 * Math.PI * i / teeth).toFloat()
|
||||||
|
val a0 = a - toothHalf * 0.55f
|
||||||
|
val a1 = a - toothHalf * 0.28f
|
||||||
|
val a2 = a + toothHalf * 0.28f
|
||||||
|
val a3 = a + toothHalf * 0.55f
|
||||||
|
val p0 = Offset(cx + kotlin.math.cos(a0) * rInner, cy + kotlin.math.sin(a0) * rInner)
|
||||||
|
if (i == 0) moveTo(p0.x, p0.y) else lineTo(p0.x, p0.y)
|
||||||
|
lineTo(cx + kotlin.math.cos(a1) * rOuter, cy + kotlin.math.sin(a1) * rOuter)
|
||||||
|
lineTo(cx + kotlin.math.cos(a2) * rOuter, cy + kotlin.math.sin(a2) * rOuter)
|
||||||
|
lineTo(cx + kotlin.math.cos(a3) * rInner, cy + kotlin.math.sin(a3) * rInner)
|
||||||
|
}
|
||||||
|
close()
|
||||||
|
addOval(
|
||||||
|
androidx.compose.ui.geometry.Rect(
|
||||||
|
cx - rHole, cy - rHole, cx + rHole, cy + rHole,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
drawPath(gear, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LockIcon(color: Color, iconSize: Dp = 18.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val body = Path().apply {
|
||||||
|
addRoundRect(
|
||||||
|
androidx.compose.ui.geometry.RoundRect(
|
||||||
|
size.width * 0.28f, size.height * 0.46f,
|
||||||
|
size.width * 0.72f, size.height * 0.82f,
|
||||||
|
androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.08f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
drawPath(body, color)
|
||||||
|
drawArc(
|
||||||
|
color,
|
||||||
|
200f, 140f, false,
|
||||||
|
Offset(size.width * 0.32f, size.height * 0.18f),
|
||||||
|
Size(size.width * 0.36f, size.height * 0.4f),
|
||||||
|
style = Stroke(size.minDimension * 0.1f, cap = StrokeCap.Round),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CheckIcon(color: Color, iconSize: Dp = 16.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val p = Path().apply {
|
||||||
|
moveTo(size.width * 0.18f, size.height * 0.52f)
|
||||||
|
lineTo(size.width * 0.4f, size.height * 0.74f)
|
||||||
|
lineTo(size.width * 0.84f, size.height * 0.26f)
|
||||||
|
}
|
||||||
|
drawPath(p, color, style = Stroke(size.minDimension * 0.16f, cap = StrokeCap.Round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WifiIcon(color: Color, iconSize: Dp = 28.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val c = Offset(size.width / 2, size.height * 0.72f)
|
||||||
|
drawCircle(color, size.minDimension * 0.06f, c)
|
||||||
|
for (i in 1..3) {
|
||||||
|
val r = size.minDimension * (0.16f + i * 0.16f)
|
||||||
|
drawArc(color, 210f, 120f, false, Offset(c.x - r, c.y - r), Size(r * 2, r * 2), style = Stroke(size.minDimension * 0.08f, cap = StrokeCap.Round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChipIcon(color: Color, iconSize: Dp = 28.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val m = size.minDimension * 0.22f
|
||||||
|
drawRoundRect(color, Offset(m, m), Size(size.width - 2 * m, size.height - 2 * m), androidx.compose.ui.geometry.CornerRadius(6f), style = Stroke(size.minDimension * 0.08f))
|
||||||
|
val pin = size.minDimension * 0.08f
|
||||||
|
for (i in 0 until 3) {
|
||||||
|
val t = m + (size.height - 2 * m) * (i + 1) / 4f
|
||||||
|
drawLine(color, Offset(m - pin, t), Offset(m, t), size.minDimension * 0.06f)
|
||||||
|
drawLine(color, Offset(size.width - m, t), Offset(size.width - m + pin, t), size.minDimension * 0.06f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GermanFlag(iconSize: Dp = 48.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val w = this.size.width
|
||||||
|
val h = this.size.height
|
||||||
|
val clip = Path().apply { addOval(androidx.compose.ui.geometry.Rect(0f, 0f, w, h)) }
|
||||||
|
clipPath(clip) {
|
||||||
|
val band = h / 3f
|
||||||
|
drawRect(Color(0xFF2E2E30), size = Size(w, band))
|
||||||
|
drawRect(Color(0xFFB8453D), topLeft = Offset(0f, band), size = Size(w, band))
|
||||||
|
drawRect(Color(0xFFD6B352), topLeft = Offset(0f, 2 * band), size = Size(w, band))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CzechFlag(iconSize: Dp = 48.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val w = this.size.width
|
||||||
|
val h = this.size.height
|
||||||
|
val clip = Path().apply { addOval(androidx.compose.ui.geometry.Rect(0f, 0f, w, h)) }
|
||||||
|
clipPath(clip) {
|
||||||
|
drawRect(Color(0xFFFAFAFA), size = Size(w, h))
|
||||||
|
drawRect(Color(0xFFD4292E), topLeft = Offset(0f, h / 2), size = Size(w, h / 2))
|
||||||
|
val tri = Path().apply {
|
||||||
|
moveTo(0f, 0f)
|
||||||
|
lineTo(w * 0.55f, h / 2)
|
||||||
|
lineTo(0f, h)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(tri, Color(0xFF123D8C))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UkFlag(iconSize: Dp = 48.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val w = this.size.width
|
||||||
|
val h = this.size.height
|
||||||
|
val clip = Path().apply { addOval(androidx.compose.ui.geometry.Rect(0f, 0f, w, h)) }
|
||||||
|
clipPath(clip) {
|
||||||
|
drawRect(Color(0xFF012466), size = Size(w, h))
|
||||||
|
drawLine(Color.White, Offset(0f, 0f), Offset(w, h), strokeWidth = w * 0.16f)
|
||||||
|
drawLine(Color.White, Offset(w, 0f), Offset(0f, h), strokeWidth = w * 0.16f)
|
||||||
|
drawLine(Color(0xFFCC0D26), Offset(0f, 0f), Offset(w, h), strokeWidth = w * 0.055f)
|
||||||
|
drawLine(Color(0xFFCC0D26), Offset(w, 0f), Offset(0f, h), strokeWidth = w * 0.055f)
|
||||||
|
drawRect(Color.White, topLeft = Offset(0f, h * 0.38f), size = Size(w, h * 0.24f))
|
||||||
|
drawRect(Color.White, topLeft = Offset(w * 0.38f, 0f), size = Size(w * 0.24f, h))
|
||||||
|
drawRect(Color(0xFFCC0D26), topLeft = Offset(0f, h * 0.43f), size = Size(w, h * 0.14f))
|
||||||
|
drawRect(Color(0xFFCC0D26), topLeft = Offset(w * 0.43f, 0f), size = Size(w * 0.14f, h))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BookIcon(color: Color, iconSize: Dp = 22.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
drawRoundRect(color, Offset(size.width * 0.22f, size.height * 0.18f), Size(size.width * 0.56f, size.height * 0.64f), androidx.compose.ui.geometry.CornerRadius(4f), style = Stroke(size.minDimension * 0.08f))
|
||||||
|
drawLine(color, Offset(size.width * 0.38f, size.height * 0.18f), Offset(size.width * 0.38f, size.height * 0.82f), size.minDimension * 0.08f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QuizIcon(color: Color, iconSize: Dp = 22.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
drawCircle(color, size.minDimension * 0.36f, Offset(size.width / 2, size.height / 2), style = Stroke(size.minDimension * 0.08f))
|
||||||
|
drawCircle(color, size.minDimension * 0.06f, Offset(size.width / 2, size.height * 0.68f))
|
||||||
|
drawLine(color, Offset(size.width / 2, size.height * 0.32f), Offset(size.width / 2, size.height * 0.52f), size.minDimension * 0.08f, StrokeCap.Round)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OrderIcon(color: Color, iconSize: Dp = 22.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
for (i in 0 until 3) {
|
||||||
|
val y = size.height * (0.28f + i * 0.22f)
|
||||||
|
drawLine(color, Offset(size.width * 0.22f, y), Offset(size.width * 0.78f, y), size.minDimension * 0.08f, StrokeCap.Round)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GlobeIcon(color: Color, iconSize: Dp = 22.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
drawCircle(color, size.minDimension * 0.34f, center, style = Stroke(size.minDimension * 0.08f))
|
||||||
|
drawOval(color, Offset(size.width * 0.32f, size.height * 0.18f), Size(size.width * 0.36f, size.height * 0.64f), style = Stroke(size.minDimension * 0.06f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PeopleIcon(color: Color, iconSize: Dp = 22.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
drawCircle(color, size.minDimension * 0.12f, Offset(size.width * 0.35f, size.height * 0.32f))
|
||||||
|
drawCircle(color, size.minDimension * 0.12f, Offset(size.width * 0.65f, size.height * 0.32f))
|
||||||
|
drawArc(color, 200f, 140f, false, Offset(size.width * 0.18f, size.height * 0.42f), Size(size.width * 0.34f, size.height * 0.4f), style = Stroke(size.minDimension * 0.08f, cap = StrokeCap.Round))
|
||||||
|
drawArc(color, 200f, 140f, false, Offset(size.width * 0.48f, size.height * 0.42f), Size(size.width * 0.34f, size.height * 0.4f), style = Stroke(size.minDimension * 0.08f, cap = StrokeCap.Round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BulbIcon(color: Color, iconSize: Dp = 22.dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
drawCircle(color, size.minDimension * 0.22f, Offset(size.width / 2, size.height * 0.4f), style = Stroke(size.minDimension * 0.08f))
|
||||||
|
drawRoundRect(color, Offset(size.width * 0.4f, size.height * 0.58f), Size(size.width * 0.2f, size.height * 0.2f), androidx.compose.ui.geometry.CornerRadius(3f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FinishFlag(p: Palette, number: String, iconSize: Dp) {
|
||||||
|
DrawnIcon(iconSize) {
|
||||||
|
val r = size.minDimension / 2f - 2f
|
||||||
|
drawCircle(p.finishDark, r)
|
||||||
|
drawCircle(p.finishStroke, r, style = Stroke(2f))
|
||||||
|
val cell = 6f
|
||||||
|
val fx = center.x - 1.5f * cell
|
||||||
|
val fy = center.y + 8f
|
||||||
|
for (iy in 0 until 2) for (ix in 0 until 3) {
|
||||||
|
val c = if ((ix + iy) and 1 == 1) p.finishLight else p.finishStroke
|
||||||
|
drawRect(c, Offset(fx + ix * cell, fy + iy * cell), Size(cell, cell))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun noteIcon(name: String, color: Color): @Composable () -> Unit = {
|
||||||
|
when (name) {
|
||||||
|
"globe" -> GlobeIcon(color)
|
||||||
|
"people" -> PeopleIcon(color)
|
||||||
|
"bulb" -> BulbIcon(color)
|
||||||
|
"quiz" -> QuizIcon(color)
|
||||||
|
"order" -> OrderIcon(color)
|
||||||
|
else -> BookIcon(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
1344
android/shared/src/org/maturita/maturita/ui/Screens.kt
Normal file
BIN
assets/app-icon.ico
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
assets/app-icon.png
Normal file
|
After Width: | Height: | Size: 872 KiB |
BIN
assets/deutsch.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
assets/graduately-icon-cap.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
assets/graduately-icon-grad.png
Normal file
|
After Width: | Height: | Size: 872 KiB |
BIN
assets/home.png
Normal file
|
After Width: | Height: | Size: 360 KiB |
BIN
assets/neue-freunde.png
Normal file
|
After Width: | Height: | Size: 704 KiB |
319
content/README.md
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
# Obsah aplikace
|
||||||
|
|
||||||
|
Všechny texty lekcí, cvičení, kvízů a knih jsou tady v Markdownu. Aplikace pro
|
||||||
|
Android, iOS, macOS, Linux i Windows z nich při sestavení vyrobí jeden soubor
|
||||||
|
`content.json` (skript `scripts/build-content.py`). Na kód se kvůli obsahu sahat
|
||||||
|
nemusí.
|
||||||
|
|
||||||
|
Kontrola po úpravě:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/build-content.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Skript vypíše počet lekcí, nebo chyby s cestou a číslem řádku
|
||||||
|
(`content/fyzika/1-rocnik/02-pohyb.md:41: …`). Při chybě se aplikace nesestaví.
|
||||||
|
|
||||||
|
## Kde co je
|
||||||
|
|
||||||
|
| Složka | Obsah |
|
||||||
|
|---|---|
|
||||||
|
| `pocitacove-site/`, `technicke-vybaveni/`, `technicka-grafika/`, `prezentacni-grafika/`, `programove-vybaveni/`, `programovani/`, `obcanska-nauka/`, `fyzika/` | `N-rocnik/`, v nich lekce |
|
||||||
|
| `prirodni-vedy/chemie`, `prirodni-vedy/biologie` | lekce |
|
||||||
|
| `cestina/literatura/N-rocnik` | lekce literatury |
|
||||||
|
| `cestina/mluvnice` | cvičení z mluvnice |
|
||||||
|
| `cestina/cetba` | knihy k maturitní četbě |
|
||||||
|
| `matematika/0-opakovani`, `matematika/N-rocnik` | lekce s příklady |
|
||||||
|
| `anglictina/N-rocnik`, `nemcina/N-rocnik` | lekce s výkladem, čtením, poslechem, psaním |
|
||||||
|
| `nemcina/ucebnice` | učebnice němčiny po kapitolách |
|
||||||
|
| `aplikace/texty.md` | texty rozhraní (tlačítka, nadpisy obrazovek) |
|
||||||
|
| `aplikace/predmety.md` | dlaždice předmětů na úvodní obrazovce |
|
||||||
|
| `aplikace/novinky.txt` | seznam změn zobrazovaný v aplikaci |
|
||||||
|
|
||||||
|
Jedna lekce je jeden soubor `NN-nazev.md`. Pořadí v aplikaci určuje číslo na
|
||||||
|
začátku názvu. Soubory, které začínají `_`, nejsou lekce (např. `_lekce.md`
|
||||||
|
v učebnici popisuje kapitolu).
|
||||||
|
|
||||||
|
## Angličtina
|
||||||
|
|
||||||
|
Anglická verze leží vedle české jako `NN-nazev.en.md`. Co v ní chybí, zobrazí
|
||||||
|
se česky. Obsahuje:
|
||||||
|
|
||||||
|
- hlavičku se stejnými poli jako česká (`nazev`, `popis`, …), ale anglicky,
|
||||||
|
- volitelně tabulku `# Překlady`, kde je každý český text z lekce přeložený:
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
nazev: Physical quantities
|
||||||
|
popis: SI, scalar and vector, measurement error.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Překlady
|
||||||
|
|
||||||
|
| Česky | Anglicky |
|
||||||
|
|---|---|
|
||||||
|
| Fyzikální veličina má číselnou hodnotu a jednotku. | A physical quantity has a value and a unit. |
|
||||||
|
```
|
||||||
|
|
||||||
|
Český text musí v tabulce sedět přesně (včetně interpunkce). Pole `id`
|
||||||
|
a `puvodni` do `.en.md` nepatří.
|
||||||
|
|
||||||
|
## Společná pravidla zápisu
|
||||||
|
|
||||||
|
Hlavička mezi `---` obsahuje pole `klíč: hodnota`. Pak následují oddíly `#`,
|
||||||
|
položky `##` a podle potřeby `###`.
|
||||||
|
|
||||||
|
| Zápis | Význam |
|
||||||
|
|---|---|
|
||||||
|
| `- text` | odrážka; v kvízu možnost |
|
||||||
|
| `- [x] text` | správná možnost |
|
||||||
|
| `1. text` | číslovaná položka (např. části děje) |
|
||||||
|
| `> text` | vysvětlení, nápověda, tip nebo význam (podle oddílu) |
|
||||||
|
| `= odpověď` | správná odpověď; více variant odděluje `\|`: `= hasn't\|has not` |
|
||||||
|
| `[odpověď]` | mezera k doplnění uvnitř věty: `Ich [bin\|heiße] Anna.` |
|
||||||
|
| `\| a \| b \|` | tabulka; v buňce se píše `\|` jako `\\|` a nový řádek jako `\n` |
|
||||||
|
| `<!-- … -->` | poznámka, do aplikace se nedostane |
|
||||||
|
|
||||||
|
Hranaté závorky, které nemají být mezerou, se píšou `\[` a `\]`. Řádek, který
|
||||||
|
by jinak vypadal jako značka (začíná `-`, `>`, `=` …), se uvede zpětným
|
||||||
|
lomítkem: `\- tohle je obyčejný text`.
|
||||||
|
|
||||||
|
## ID a postup žáků
|
||||||
|
|
||||||
|
```md
|
||||||
|
id: fyzika-1-fyzikalni-veliciny
|
||||||
|
puvodni: fyz.1
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`id`** je trvalé jméno lekce. Pod ním se ukládá, co má žák hotové. Musí být
|
||||||
|
v celém obsahu jedinečné. **Po vydání ho neměňte**, jinak se žákům postup
|
||||||
|
v lekci ztratí. Název souboru, číslo pořadí i nadpis měnit lze.
|
||||||
|
- **`puvodni`** je adresa lekce ze starších verzí aplikace. Podle ní se při
|
||||||
|
prvním spuštění nové verze převede starý postup. Neměňte ji a u nových lekcí
|
||||||
|
ji nepište.
|
||||||
|
|
||||||
|
Nová lekce: zkopírujte podobný soubor, dejte mu číslo podle pořadí, nové `id`
|
||||||
|
a smažte `puvodni`.
|
||||||
|
|
||||||
|
## Běžná lekce
|
||||||
|
|
||||||
|
Sítě, hardware, technická grafika, prezentační grafika, programové vybavení, občanka, literatura, chemie, biologie, fyzika.
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
id: fyzika-1-fyzikalni-veliciny
|
||||||
|
nazev: Fyzikální veličiny
|
||||||
|
popis: SI, skalár a vektor, chyba měření.
|
||||||
|
nazev_cviceni: Kvíz: veličiny
|
||||||
|
nadpis_kvizu: Ověřte si jednotky a chyby
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
|
||||||
|
## Veličina a jednotka
|
||||||
|
*Měření*
|
||||||
|
|
||||||
|
- Fyzikální veličina má číselnou hodnotu a jednotku.
|
||||||
|
- Soustava SI má sedm základních jednotek.
|
||||||
|
|
||||||
|
> Tip: základní jednotka hmotnosti je kilogram, ne gram.
|
||||||
|
|
||||||
|
# Kvíz
|
||||||
|
|
||||||
|
## Která jednotka je základní jednotka SI pro hmotnost?
|
||||||
|
- Gram
|
||||||
|
- [x] Kilogram
|
||||||
|
- Newton
|
||||||
|
> Gram je tisícina kilogramu. Newton je jednotka síly.
|
||||||
|
```
|
||||||
|
|
||||||
|
Každá `##` ve výkladu je jeden snímek. Řádek `*Měření*` je štítek snímku,
|
||||||
|
počítadlo „1 / 3“ doplní aplikace sama.
|
||||||
|
|
||||||
|
V první lekci sítí je navíc oddíl `# Úlohy na podsítě`: `## Úloha N` se
|
||||||
|
zadáním, `### Řešení` s textem řešení a `### Kontrola` s tabulkou
|
||||||
|
`Prefix | Síť | Broadcast | První adresa | Poslední adresa`.
|
||||||
|
|
||||||
|
## Matematika
|
||||||
|
|
||||||
|
Stejná hlavička a `# Výklad` jako běžná lekce, místo kvízu `# Příklady`:
|
||||||
|
|
||||||
|
```md
|
||||||
|
# Příklady
|
||||||
|
|
||||||
|
## Jaký je největší společný dělitel čísel 24 a 36?
|
||||||
|
= num:12
|
||||||
|
> Společní dělitelé jsou 1, 2, 3, 4, 6 a 12.
|
||||||
|
```
|
||||||
|
|
||||||
|
Druhy odpovědí:
|
||||||
|
|
||||||
|
| Odpověď | Kdy je správně |
|
||||||
|
|---|---|
|
||||||
|
| `num:12`, `num:1/2` | číslo nebo výraz (zlomek, odmocnina, π); stačí stejná hodnota v jakémkoli tvaru |
|
||||||
|
| `set:2;3` | všechna čísla v libovolném pořadí |
|
||||||
|
| `pair:2;-1` | čísla v daném pořadí (např. x; y) |
|
||||||
|
| `word:ano\|yes` | jedno ze slov |
|
||||||
|
| `square:2,3;5,3`, `right:…`, `mid:…`, `para:…`, `points:1,4;fix:1,1` | rýsování do mřížky 10 × 8; `fix:` jsou body předem zakreslené |
|
||||||
|
|
||||||
|
## Angličtina a němčina po ročnících
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
id: anglictina-1-ja-a-moje-rodina
|
||||||
|
nazev: Já a moje rodina
|
||||||
|
popis: Sloveso be, have got. Doplňování a kvíz.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
(snímky jako u běžné lekce)
|
||||||
|
|
||||||
|
# Čtení
|
||||||
|
Text článku jedním odstavcem.
|
||||||
|
|
||||||
|
## The bus is ___ than the train.
|
||||||
|
- faster
|
||||||
|
- [x] slower
|
||||||
|
> cheaper, but slower.
|
||||||
|
|
||||||
|
# Poslech
|
||||||
|
Přepis nahrávky, pak otázky stejně jako u čtení.
|
||||||
|
|
||||||
|
# Doplňování
|
||||||
|
|
||||||
|
## My sister ___ sixteen.
|
||||||
|
= is
|
||||||
|
> Věk: be, ne have.
|
||||||
|
|
||||||
|
# Psaní
|
||||||
|
Zadání.
|
||||||
|
|
||||||
|
## Vzorová odpověď
|
||||||
|
Text.
|
||||||
|
|
||||||
|
## Musí obsahovat
|
||||||
|
- would
|
||||||
|
- because
|
||||||
|
|
||||||
|
## Minimum slov
|
||||||
|
35
|
||||||
|
|
||||||
|
# Kvíz
|
||||||
|
(jako u běžné lekce)
|
||||||
|
```
|
||||||
|
|
||||||
|
Oddíly, které lekce nepotřebuje, se vynechají.
|
||||||
|
|
||||||
|
## Mluvnice (`cestina/mluvnice`)
|
||||||
|
|
||||||
|
Hlavička: `id`, `typ`, `zkratka` (krátký název na mapě), `nazev`, `popis`,
|
||||||
|
volitelně `poznamka`. Úlohy jsou v oddílu `# Úlohy`, podle typu:
|
||||||
|
|
||||||
|
```md
|
||||||
|
typ: doplnovani
|
||||||
|
|
||||||
|
## Stará bab_čka sed_la na lav_čce.
|
||||||
|
- i/í/y/ý = babička
|
||||||
|
- i/í/y/ý = seděla|sedla
|
||||||
|
> babička, seděla, lavičce
|
||||||
|
```
|
||||||
|
|
||||||
|
Každá odrážka je jedno políčko: vlevo nápověda, za `=` správné odpovědi.
|
||||||
|
|
||||||
|
```md
|
||||||
|
typ: vyber
|
||||||
|
|
||||||
|
## Narodil se v ______ v malé vesnici.
|
||||||
|
- [x] Čechách
|
||||||
|
- čechách
|
||||||
|
```
|
||||||
|
|
||||||
|
```md
|
||||||
|
typ: reseni
|
||||||
|
|
||||||
|
## napsali
|
||||||
|
= na- / ps- / -a- / -li
|
||||||
|
```
|
||||||
|
|
||||||
|
U `reseni` si žák řešení jen odkryje.
|
||||||
|
|
||||||
|
## Četba (`cestina/cetba`)
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
id: 1984
|
||||||
|
autor: George Orwell
|
||||||
|
nazev: 1984
|
||||||
|
zanr: Próza
|
||||||
|
preklad: Šimečková
|
||||||
|
---
|
||||||
|
|
||||||
|
# O knize
|
||||||
|
- Autor: George Orwell
|
||||||
|
|
||||||
|
# Postavy
|
||||||
|
- Winston Smith – hlavní hrdina.
|
||||||
|
|
||||||
|
# Kvíz
|
||||||
|
## Kdo je hlavní hrdina?
|
||||||
|
- [x] Winston Smith
|
||||||
|
- O'Brien
|
||||||
|
> Vysvětlení.
|
||||||
|
|
||||||
|
# Děj
|
||||||
|
## Přetáhněte části příběhu do správného pořadí:
|
||||||
|
1. První část děje.
|
||||||
|
2. Druhá část děje.
|
||||||
|
> Volitelně význam celého děje.
|
||||||
|
```
|
||||||
|
|
||||||
|
Všechny oddíly kromě `Kvíz` a `Děj` jsou poznámky ke knize, zobrazí se
|
||||||
|
v uvedeném pořadí. Kvíz i děj jsou nepovinné. Volitelná pole v hlavičce:
|
||||||
|
`popis`, `kviz_nadpis`, `kviz_popis`, `dej_nadpis`, `dej_popis` (jinak se
|
||||||
|
použijí obecné texty) a `stranka`/`plna` pro knihy s vlastní obrazovkou.
|
||||||
|
|
||||||
|
## Učebnice němčiny (`nemcina/ucebnice`)
|
||||||
|
|
||||||
|
Každá kapitola je složka `NN-nazev/` se souborem `_lekce.md`:
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
id: ucebnice-1
|
||||||
|
nazev: Neue Freunde
|
||||||
|
popis: Vyberte cvičení a dokončete je.
|
||||||
|
zamceno: ano
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
(`zamceno: ano` kapitolu v aplikaci zamkne.) V kapitole jsou cvičení
|
||||||
|
`NN-nazev.md` a slovíčka `slovicka.md`. Každé cvičení má v hlavičce `typ`.
|
||||||
|
Nejčastější typy:
|
||||||
|
|
||||||
|
| Typ | Oddíly |
|
||||||
|
|---|---|
|
||||||
|
| `choice` | `# Otázky` jako kvíz |
|
||||||
|
| `typed` | `# Úlohy`: tabulka `Zadání \| Odpověď \| Význam` |
|
||||||
|
| `verb`, `fill` | `# Nabídka` (odrážky slov), `# Úlohy`: tabulka s větami a mezerami `[…]` |
|
||||||
|
| `dialog` | `# Nabídka`, `# Rozhovory` s `## názvem` a tabulkou `Kdo \| Věta` |
|
||||||
|
| `assign` | `# Skupiny` (odrážky), `# Slova`: tabulka `Obrázek \| Slovo \| Skupina` |
|
||||||
|
| `free` | `# Otázky`: tabulka `Otázka \| Překlad otázky \| Vzorová odpověď \| Překlad odpovědi` |
|
||||||
|
| `vocab` | `# Slovíčka`, `## Strana N` a tabulka `Německy \| Odpověď \| Význam` |
|
||||||
|
|
||||||
|
Další typy (`assembly`, `number`, `count`, `seq`, `verbclue`, `profile`,
|
||||||
|
`hangman`, `ordne`, `letters`, `ex2`, `table`, `kw` …) mají vždy aspoň jeden
|
||||||
|
vzor v existujících kapitolách; nejjednodušší je zkopírovat ho. Oddíl
|
||||||
|
`# Překlad` obsahuje české překlady položek ve stejném pořadí.
|
||||||
|
|
||||||
|
Volitelná pole hlavičky cvičení: `tip`, `ukazka` (vzorový příklad),
|
||||||
|
`vyznam_predem`, `preklad_predem`, `odhalit_nemcinu`, `ukazat_nemcinu`
|
||||||
|
(`ano`/`ne`) a `poznamka_k_odpovedim`.
|
||||||
|
|
||||||
|
## Texty rozhraní a předměty
|
||||||
|
|
||||||
|
`aplikace/texty.md` obsahuje tabulky `Klíč | Česky | Anglicky`. Klíč používá
|
||||||
|
kód aplikace, neměňte ho; texty ve sloupcích měnit lze. `%s` a `%d` jsou místa,
|
||||||
|
kam aplikace doplní hodnotu.
|
||||||
|
|
||||||
|
`aplikace/predmety.md` má tabulku
|
||||||
|
`Klíč | Česky | Anglicky | Ikona | Cíl | Otevřeno`. `Cíl` je obrazovka, která se
|
||||||
|
otevře, `Otevřeno: ne` dlaždici zamkne.
|
||||||
4
content/anglictina/1-rocnik/01-ja-a-moje-rodina.en.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
nazev: Me and my family
|
||||||
|
popis: The verb be, have got and possessives. Gaps and a quiz.
|
||||||
|
---
|
||||||
82
content/anglictina/1-rocnik/01-ja-a-moje-rodina.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
---
|
||||||
|
id: anglictina-1-ja-a-moje-rodina
|
||||||
|
puvodni: en.1.1
|
||||||
|
nazev: Já a moje rodina
|
||||||
|
popis: Sloveso be, have got a přivlastňovací zájmena. Doplňování a kvíz.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
|
||||||
|
## Kdo jsem
|
||||||
|
*Be*
|
||||||
|
|
||||||
|
- I am, you are, he/she/it is, we/you/they are.
|
||||||
|
- Zápor a otázka: She isn't tall. Are you from Prague?
|
||||||
|
- Věk, povolání i nálada: I am 16. My mum is a nurse. I am fine.
|
||||||
|
- Přivlastňovací zájmena: my, your, his, her, its, our, their.
|
||||||
|
|
||||||
|
> Tip: věk se říká slovesem be. „She has 16 years“ je čechismus.
|
||||||
|
|
||||||
|
## Co mám
|
||||||
|
*Have got*
|
||||||
|
|
||||||
|
- I've got a younger brother. She hasn't got a pet.
|
||||||
|
- Have you got any cousins? — Yes, I have. / No, I haven't.
|
||||||
|
- V americké angličtině stačí have: I have a brother.
|
||||||
|
- Rodina: parents, siblings, only child, get on well, look like.
|
||||||
|
|
||||||
|
> Tip: have got je britská hovorová vazba pro vlastnictví, ne pro věk.
|
||||||
|
|
||||||
|
|
||||||
|
# Doplňování
|
||||||
|
|
||||||
|
## My sister ___ sixteen.
|
||||||
|
= is
|
||||||
|
> Věk: be, ne have.
|
||||||
|
|
||||||
|
## ___ you got a bike?
|
||||||
|
= Have
|
||||||
|
> Otázka s have got.
|
||||||
|
|
||||||
|
## This is ___ grandmother. She lives with us.
|
||||||
|
= our
|
||||||
|
> Přivlastňovací zájmeno.
|
||||||
|
|
||||||
|
## They ___ not from Brno. They are from Olomouc.
|
||||||
|
= are
|
||||||
|
> They + are.
|
||||||
|
|
||||||
|
## He ___ got a dog, but he has got two cats.
|
||||||
|
= hasn't|has not
|
||||||
|
> Zápor have got.
|
||||||
|
|
||||||
|
|
||||||
|
# Kvíz
|
||||||
|
|
||||||
|
## Which sentence is correct?
|
||||||
|
- She has 16 years.
|
||||||
|
- [x] She is 16 years old.
|
||||||
|
- She is 16 years.
|
||||||
|
- She has 16 years old.
|
||||||
|
> Věk: be + years old.
|
||||||
|
|
||||||
|
## I ___ two cousins and one aunt.
|
||||||
|
- am
|
||||||
|
- [x] have got
|
||||||
|
- has got
|
||||||
|
- is
|
||||||
|
> Vlastnictví v 1. osobě: have got.
|
||||||
|
|
||||||
|
## Eva is my sister. ___ room is next to mine.
|
||||||
|
- She
|
||||||
|
- [x] Her
|
||||||
|
- His
|
||||||
|
- Hers
|
||||||
|
> Před podstatným jménem je her, ne she.
|
||||||
|
|
||||||
|
## ___ your parents at home?
|
||||||
|
- Is
|
||||||
|
- [x] Are
|
||||||
|
- Have
|
||||||
|
- Do
|
||||||
|
> Parents je množné číslo: Are.
|
||||||
4
content/anglictina/1-rocnik/02-skolni-den.en.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
nazev: A school day
|
||||||
|
popis: The present simple. Reading and gaps.
|
||||||
|
---
|
||||||
80
content/anglictina/1-rocnik/02-skolni-den.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
id: anglictina-1-skolni-den
|
||||||
|
puvodni: en.1.2
|
||||||
|
nazev: Školní den
|
||||||
|
popis: Přítomný čas prostý. Čtení a doplňování.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
|
||||||
|
## Co dělám pravidelně
|
||||||
|
*Present simple*
|
||||||
|
|
||||||
|
- I get up at seven. He gets up at six.
|
||||||
|
- Zápor: I don't like maths. She doesn't eat meat.
|
||||||
|
- Otázka: Do you walk to school? Does she play the piano?
|
||||||
|
- Příslovce četnosti stojí před slovesem: I usually walk. She is always late.
|
||||||
|
|
||||||
|
> Tip: ve 3. osobě jednotného čísla přibývá -s/-es: she watches.
|
||||||
|
|
||||||
|
## Rozvrh a přestávky
|
||||||
|
*Škola*
|
||||||
|
|
||||||
|
- lessons, break, canteen, homework, timetable, subject.
|
||||||
|
- Čas: at eight, at half past twelve, in the morning, on Monday.
|
||||||
|
- because vysvětluje důvod: I like biology because we do experiments.
|
||||||
|
- never / sometimes / often / usually / always.
|
||||||
|
|
||||||
|
> Tip: předmět ve škole je bez členu, když mluvíme obecně: I like biology.
|
||||||
|
|
||||||
|
|
||||||
|
# Čtení
|
||||||
|
|
||||||
|
My school day starts early. I get up at half past six, have breakfast and catch the tram. Lessons begin at eight. On Monday we have maths, English and biology. I like biology because we do experiments, but I don't like maths. We have lunch at half past twelve in the canteen. After school I do my homework or meet friends. I never go to bed after ten on a school night.
|
||||||
|
|
||||||
|
## What time do lessons begin?
|
||||||
|
- At half past six
|
||||||
|
- [x] At eight
|
||||||
|
- At half past twelve
|
||||||
|
- At ten
|
||||||
|
> Lessons begin at eight.
|
||||||
|
|
||||||
|
## Why does the writer like biology?
|
||||||
|
- Because it is easy
|
||||||
|
- [x] Because they do experiments
|
||||||
|
- Because the teacher is funny
|
||||||
|
- Because there is no homework
|
||||||
|
> because we do experiments.
|
||||||
|
|
||||||
|
## Where do they have lunch?
|
||||||
|
- At home
|
||||||
|
- On the tram
|
||||||
|
- [x] In the canteen
|
||||||
|
- In the park
|
||||||
|
> in the canteen.
|
||||||
|
|
||||||
|
## On a school night the writer goes to bed…
|
||||||
|
- after eleven
|
||||||
|
- at midnight
|
||||||
|
- [x] never after ten
|
||||||
|
- before six
|
||||||
|
> never go to bed after ten.
|
||||||
|
|
||||||
|
|
||||||
|
# Doplňování
|
||||||
|
|
||||||
|
## She ___ (watch) a series every evening.
|
||||||
|
= watches
|
||||||
|
> 3. osoba: watches.
|
||||||
|
|
||||||
|
## We ___ (not go) to school on Sunday.
|
||||||
|
= don't go|do not go
|
||||||
|
> Zápor v 1. osobě množného čísla.
|
||||||
|
|
||||||
|
## ___ he play football on Fridays?
|
||||||
|
= Does
|
||||||
|
> Otázka ve 3. osobě.
|
||||||
|
|
||||||
|
## I usually ___ (get) up at seven.
|
||||||
|
= get
|
||||||
|
> Usually nemění tvar slovesa.
|
||||||
4
content/anglictina/1-rocnik/03-vikend-ve-meste.en.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
nazev: A weekend in town
|
||||||
|
popis: The present continuous for arrangements. Listening.
|
||||||
|
---
|
||||||
80
content/anglictina/1-rocnik/03-vikend-ve-meste.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
id: anglictina-1-vikend-ve-meste
|
||||||
|
puvodni: en.1.3
|
||||||
|
nazev: Víkend ve městě
|
||||||
|
popis: Přítomný čas průběhový pro domluvené plány. Poslech.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
|
||||||
|
## Co právě probíhá a co je domluvené
|
||||||
|
*Continuous*
|
||||||
|
|
||||||
|
- Tvar: am/is/are + -ing. I'm waiting. She isn't coming.
|
||||||
|
- Teď: Look, it is raining. Please be quiet, the baby is sleeping.
|
||||||
|
- Blízký plán: We're having lunch on Saturday. I'm playing volleyball.
|
||||||
|
- Stavová slovesa obvykle průběhový čas nemají: I know, I like, I want.
|
||||||
|
|
||||||
|
> Tip: I'm meeting Eva at ten je plán, ne jen „potkávám“.
|
||||||
|
|
||||||
|
## Jak poslouchat vzkaz
|
||||||
|
*Poslech*
|
||||||
|
|
||||||
|
- Chyťte jména, časy a místa. Ta se v otázkách opakují.
|
||||||
|
- Nenechte se zastavit jedním slovem. Poslouchejte dál.
|
||||||
|
- I'm meeting, we're going, I'm playing jsou v tomhle vzkazu klíčové.
|
||||||
|
- Na konci bývá prosba: Call me back.
|
||||||
|
|
||||||
|
> Tip: nejdřív si přečtěte otázky, pak teprve pusťte nahrávku.
|
||||||
|
|
||||||
|
|
||||||
|
# Poslech
|
||||||
|
|
||||||
|
Hi Tom, it's Anna. Are you free on Saturday? I'm meeting Eva at the station at ten. We're going to the market first, and then we're having lunch in that new café by the river. In the afternoon I'm playing volleyball with my class in the park. Do you want to come to the café? Call me back tonight.
|
||||||
|
|
||||||
|
## Who is Anna meeting?
|
||||||
|
- Tom
|
||||||
|
- [x] Eva
|
||||||
|
- Her class
|
||||||
|
- Her mum
|
||||||
|
> I'm meeting Eva.
|
||||||
|
|
||||||
|
## Where are they meeting?
|
||||||
|
- At school
|
||||||
|
- [x] At the station
|
||||||
|
- In the park
|
||||||
|
- By the river only
|
||||||
|
> at the station at ten.
|
||||||
|
|
||||||
|
## What is Anna doing in the afternoon?
|
||||||
|
- Going to the market
|
||||||
|
- Having lunch
|
||||||
|
- [x] Playing volleyball
|
||||||
|
- Sleeping
|
||||||
|
> I'm playing volleyball.
|
||||||
|
|
||||||
|
## What should Tom do?
|
||||||
|
- Buy tickets
|
||||||
|
- [x] Call her back
|
||||||
|
- Come to school
|
||||||
|
- Bring a map
|
||||||
|
> Call me back tonight.
|
||||||
|
|
||||||
|
|
||||||
|
# Doplňování
|
||||||
|
|
||||||
|
## Listen, the phone ___.
|
||||||
|
= is ringing
|
||||||
|
> Děje se to teď.
|
||||||
|
|
||||||
|
## We ___ lunch with Eva on Saturday.
|
||||||
|
= are having|'re having
|
||||||
|
> Domluvený plán.
|
||||||
|
|
||||||
|
## I ___ not coming to the match.
|
||||||
|
= am|'m
|
||||||
|
> Zápor: am not.
|
||||||
|
|
||||||
|
## She ___ volleyball this afternoon.
|
||||||
|
= is playing|'s playing
|
||||||
|
> Domluvená aktivita.
|
||||||
10
content/anglictina/1-rocnik/04-muj-pokoj.en.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
---
|
||||||
|
nazev: My room
|
||||||
|
popis: There is / there are and prepositions. A short description and a quiz.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Překlady
|
||||||
|
|
||||||
|
| Česky | Anglicky |
|
||||||
|
|---|---|
|
||||||
|
| on | he |
|
||||||
78
content/anglictina/1-rocnik/04-muj-pokoj.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
id: anglictina-1-muj-pokoj
|
||||||
|
puvodni: en.1.4
|
||||||
|
nazev: Můj pokoj
|
||||||
|
popis: There is / there are a předložky místa. Krátký popis a kvíz.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
|
||||||
|
## Co v pokoji je
|
||||||
|
*There is*
|
||||||
|
|
||||||
|
- There is a desk under the window. There are two chairs.
|
||||||
|
- Zápor: There isn't a TV. There aren't any posters.
|
||||||
|
- Otázka: Is there a lamp? Are there any books?
|
||||||
|
- Předložky: in, on, under, next to, between, behind, in front of.
|
||||||
|
|
||||||
|
> Tip: there is pro jedno, there are pro více. There je formální podmět.
|
||||||
|
|
||||||
|
## Popis místa
|
||||||
|
*Psaní*
|
||||||
|
|
||||||
|
- Začněte velikostí a světlem: small but bright.
|
||||||
|
- Každá věta ať má věc a místo: The bag is under the chair.
|
||||||
|
- Střídejte there is a there are, ať text není seznam.
|
||||||
|
- Na konci jedna osobní věta: I like it because it is quiet.
|
||||||
|
|
||||||
|
> Tip: jděte od velkého k malému, od dveří k oknu.
|
||||||
|
|
||||||
|
|
||||||
|
# Psaní
|
||||||
|
|
||||||
|
Describe your room in at least 30 words. Say what is in it and where things are. Use on and a piece of furniture such as a bed, and mention a window.
|
||||||
|
|
||||||
|
## Vzorová odpověď
|
||||||
|
|
||||||
|
My room is small but bright. There is a bed next to the window and a wooden desk under a shelf. My books are on the desk and my school bag is under the chair. There are two posters on the wall. I like the room because it is quiet in the evening.
|
||||||
|
|
||||||
|
## Musí obsahovat
|
||||||
|
|
||||||
|
- bed
|
||||||
|
- window
|
||||||
|
- on
|
||||||
|
|
||||||
|
## Minimum slov
|
||||||
|
|
||||||
|
30
|
||||||
|
|
||||||
|
|
||||||
|
# Kvíz
|
||||||
|
|
||||||
|
## The lamp is ___ the desk.
|
||||||
|
- under
|
||||||
|
- [x] on
|
||||||
|
- between
|
||||||
|
- at
|
||||||
|
> Lampa stojí na desce: on.
|
||||||
|
|
||||||
|
## There ___ a big window.
|
||||||
|
- are
|
||||||
|
- [x] is
|
||||||
|
- be
|
||||||
|
- have
|
||||||
|
> Jedno okno: there is.
|
||||||
|
|
||||||
|
## There ___ two posters on the wall.
|
||||||
|
- is
|
||||||
|
- [x] are
|
||||||
|
- has
|
||||||
|
- been
|
||||||
|
> Dva plakáty: there are.
|
||||||
|
|
||||||
|
## The cat is sleeping ___ the bed.
|
||||||
|
- [x] under
|
||||||
|
- next
|
||||||
|
- between to
|
||||||
|
- at to
|
||||||
|
> Pod postelí: under the bed.
|
||||||
4
content/anglictina/1-rocnik/05-na-trhu.en.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
nazev: At the market
|
||||||
|
popis: Countability, some and any, how much and how many. Reading and gaps.
|
||||||
|
---
|
||||||
80
content/anglictina/1-rocnik/05-na-trhu.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
id: anglictina-1-na-trhu
|
||||||
|
puvodni: en.1.5
|
||||||
|
nazev: Na trhu
|
||||||
|
popis: Počitatelnost, some a any, how much a how many. Čtení a doplňování.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
|
||||||
|
## Počitatelné a nepočitatelné
|
||||||
|
*Množství*
|
||||||
|
|
||||||
|
- Počitatelné: an apple, some apples. Nepočitatelné: some bread, some milk.
|
||||||
|
- How many apples? How much milk?
|
||||||
|
- a little milk, a few rolls, a lot of cheese.
|
||||||
|
- V obchodě: How much is it? It's four euros a kilo.
|
||||||
|
|
||||||
|
> Tip: some v kladné větě, any v otázce a záporu. Nabídka je výjimka: Would you like some tea?
|
||||||
|
|
||||||
|
## Zdvořilá žádost
|
||||||
|
*Dialog*
|
||||||
|
|
||||||
|
- Have you got any bread? — Sorry, we haven't got any today.
|
||||||
|
- We've got some rolls instead.
|
||||||
|
- How many would you like? — Six, please.
|
||||||
|
- Anything else? — No, that's all. Thank you.
|
||||||
|
|
||||||
|
> Tip: Can I have… a I'd like… zní v obchodě přirozeně.
|
||||||
|
|
||||||
|
|
||||||
|
# Čtení
|
||||||
|
|
||||||
|
On Saturday morning the market by the river is busy. A woman asks, "How much is this cheese?" The seller says, "It's four euros a kilo." Then she asks, "Have you got any bread?" He answers, "Sorry, we haven't got any bread today, but we've got some fresh rolls." "How many rolls do you want?" "Six, please, and a little milk." She also buys a few apples. "Anything else?" "No, that's all."
|
||||||
|
|
||||||
|
## How much is the cheese?
|
||||||
|
- Four euros a piece
|
||||||
|
- [x] Four euros a kilo
|
||||||
|
- Six euros
|
||||||
|
- A little
|
||||||
|
> four euros a kilo.
|
||||||
|
|
||||||
|
## What hasn't the seller got?
|
||||||
|
- Rolls
|
||||||
|
- Milk
|
||||||
|
- [x] Bread
|
||||||
|
- Apples
|
||||||
|
> haven't got any bread today.
|
||||||
|
|
||||||
|
## How many rolls does the woman want?
|
||||||
|
- Four
|
||||||
|
- A few
|
||||||
|
- [x] Six
|
||||||
|
- A kilo
|
||||||
|
> Six, please.
|
||||||
|
|
||||||
|
## Which phrase is an offer of more shopping?
|
||||||
|
- That's all.
|
||||||
|
- [x] Anything else?
|
||||||
|
- How much is it?
|
||||||
|
- A little milk.
|
||||||
|
> Anything else? nabízí další nákup.
|
||||||
|
|
||||||
|
|
||||||
|
# Doplňování
|
||||||
|
|
||||||
|
## We haven't got ___ bread today.
|
||||||
|
= any
|
||||||
|
> Zápor: any.
|
||||||
|
|
||||||
|
## There are ___ rolls in the bag.
|
||||||
|
= some
|
||||||
|
> Kladná věta: some.
|
||||||
|
|
||||||
|
## How ___ apples do you want?
|
||||||
|
= many
|
||||||
|
> Apples se dají počítat.
|
||||||
|
|
||||||
|
## How ___ milk is in the bottle?
|
||||||
|
= much
|
||||||
|
> Milk je nepočitatelné.
|
||||||
4
content/anglictina/1-rocnik/06-minule-leto.en.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
nazev: Last summer
|
||||||
|
popis: The past simple, regular and irregular verbs. Listening and a quiz.
|
||||||
|
---
|
||||||
92
content/anglictina/1-rocnik/06-minule-leto.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
---
|
||||||
|
id: anglictina-1-minule-leto
|
||||||
|
puvodni: en.1.6
|
||||||
|
nazev: Minulé léto
|
||||||
|
popis: Minulý čas prostý, pravidelná i nepravidelná slovesa. Poslech a kvíz.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
|
||||||
|
## Co se stalo a je dokončené
|
||||||
|
*Past simple*
|
||||||
|
|
||||||
|
- Pravidelná: play → played, visit → visited, try → tried, stop → stopped.
|
||||||
|
- Nepravidelná: go → went, take → took, swim → swam, buy → bought.
|
||||||
|
- I didn't stay in a hotel. Did you visit the island?
|
||||||
|
- Signály: yesterday, last July, ago, in 2019, when I was ten.
|
||||||
|
|
||||||
|
> Tip: zápor a otázka používají did, sloveso zůstává v základním tvaru.
|
||||||
|
|
||||||
|
## Jedna událost za druhou
|
||||||
|
*Vyprávění*
|
||||||
|
|
||||||
|
- Začněte kdy a kam: Last July we went to Croatia.
|
||||||
|
- Pak doprava, počasí a jedna konkrétní příhoda.
|
||||||
|
- Nepravidelná slovesa se neučí po jednom u testu, ale ve větě.
|
||||||
|
- Na konci dojem: It was the best week of the year.
|
||||||
|
|
||||||
|
> Tip: then, after that a one day drží příběh pohromadě.
|
||||||
|
|
||||||
|
|
||||||
|
# Poslech
|
||||||
|
|
||||||
|
Last July we went to Croatia. We travelled by train and then by bus because the flight was too expensive. The weather was hot every day. I swam in the morning and my brother tried surfing. He fell off the board a lot, but he loved it. One day we visited a small island. We didn't stay in a hotel. We stayed with our uncle near the beach. I took hundreds of photos.
|
||||||
|
|
||||||
|
## Where did they go?
|
||||||
|
- Italy
|
||||||
|
- [x] Croatia
|
||||||
|
- Greece
|
||||||
|
- Spain
|
||||||
|
> went to Croatia.
|
||||||
|
|
||||||
|
## How did they travel?
|
||||||
|
- By plane
|
||||||
|
- [x] By train and bus
|
||||||
|
- By car
|
||||||
|
- By ferry only
|
||||||
|
> by train and then by bus.
|
||||||
|
|
||||||
|
## What did the brother try?
|
||||||
|
- Swimming
|
||||||
|
- [x] Surfing
|
||||||
|
- Cooking
|
||||||
|
- Driving
|
||||||
|
> tried surfing.
|
||||||
|
|
||||||
|
## Where did they stay?
|
||||||
|
- In a hotel
|
||||||
|
- On the island
|
||||||
|
- [x] With their uncle
|
||||||
|
- On the train
|
||||||
|
> stayed with our uncle.
|
||||||
|
|
||||||
|
|
||||||
|
# Kvíz
|
||||||
|
|
||||||
|
## go → ___
|
||||||
|
- goed
|
||||||
|
- [x] went
|
||||||
|
- gone
|
||||||
|
- going
|
||||||
|
> go, went, gone.
|
||||||
|
|
||||||
|
## I ___ a lot of photos.
|
||||||
|
- taked
|
||||||
|
- [x] took
|
||||||
|
- taken
|
||||||
|
- token
|
||||||
|
> take, took, taken.
|
||||||
|
|
||||||
|
## She ___ in the sea every morning.
|
||||||
|
- swimmed
|
||||||
|
- [x] swam
|
||||||
|
- swum
|
||||||
|
- swims
|
||||||
|
> Minulý tvar swim je swam.
|
||||||
|
|
||||||
|
## We ___ stay in a hotel.
|
||||||
|
- don't
|
||||||
|
- [x] didn't
|
||||||
|
- wasn't
|
||||||
|
- haven't
|
||||||
|
> Zápor minulého času: didn't + sloveso.
|
||||||
4
content/anglictina/1-rocnik/07-zprava-kamaradovi.en.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
nazev: A message to a friend
|
||||||
|
popis: Will and going to. A short informal message and gaps.
|
||||||
|
---
|
||||||
66
content/anglictina/1-rocnik/07-zprava-kamaradovi.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
---
|
||||||
|
id: anglictina-1-zprava-kamaradovi
|
||||||
|
puvodni: en.1.7
|
||||||
|
nazev: Zpráva kamarádovi
|
||||||
|
popis: Will a going to. Krátká neformální zpráva a doplňování.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
|
||||||
|
## Plán, nebo rozhodnutí teď
|
||||||
|
*Budoucnost*
|
||||||
|
|
||||||
|
- We've got tickets. We're going to leave at six. To je plán.
|
||||||
|
- Look at those clouds. It's going to rain. To je důkaz.
|
||||||
|
- The phone is ringing. I'll answer it. Rozhodnutí teď.
|
||||||
|
- I think I'll stay at home. Will jako názor, ne jistý plán.
|
||||||
|
|
||||||
|
> Tip: going to má důkaz nebo plán. Will je rozhodnutí v tu chvíli nebo odhad.
|
||||||
|
|
||||||
|
## Neformální styl
|
||||||
|
*Zpráva*
|
||||||
|
|
||||||
|
- Začněte Hi a jménem, ne Dear Sir.
|
||||||
|
- Napište kdy, co a proč. Jedna věta s because stačí.
|
||||||
|
- I'm going to… je v takové zprávě přirozené.
|
||||||
|
- Konec: Want to come? Write back.
|
||||||
|
|
||||||
|
> Tip: Hi, kontraktované tvary a na konci otázka kamarádovi.
|
||||||
|
|
||||||
|
|
||||||
|
# Doplňování
|
||||||
|
|
||||||
|
## Look at those clouds. It ___ rain.
|
||||||
|
= is going to|'s going to
|
||||||
|
> Je vidět důkaz: going to.
|
||||||
|
|
||||||
|
## I think I ___ stay at home tonight.
|
||||||
|
= will|'ll
|
||||||
|
> Názor v tu chvíli: will.
|
||||||
|
|
||||||
|
## The tickets are in my bag. We ___ leave at six.
|
||||||
|
= are going to|'re going to
|
||||||
|
> Už je to domluvené.
|
||||||
|
|
||||||
|
## Don't worry. I ___ help you with the bags.
|
||||||
|
= will|'ll
|
||||||
|
> Rozhodnutí teď: will.
|
||||||
|
|
||||||
|
|
||||||
|
# Psaní
|
||||||
|
|
||||||
|
Write a short message to a friend about next weekend. Use at least 25 words. Say what you are going to do, mention the weekend, and give a reason with because.
|
||||||
|
|
||||||
|
## Vzorová odpověď
|
||||||
|
|
||||||
|
Hi Lena, next weekend I am going to visit my cousins in Pilsen. We are going to the cinema on Saturday because a new film is on. On Sunday we are going to cook lunch together. Do you want to come with me? Write back soon.
|
||||||
|
|
||||||
|
## Musí obsahovat
|
||||||
|
|
||||||
|
- going
|
||||||
|
- weekend
|
||||||
|
- because
|
||||||
|
|
||||||
|
## Minimum slov
|
||||||
|
|
||||||
|
25
|
||||||
4
content/anglictina/1-rocnik/08-test-1-rocniku.en.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
nazev: Year 1 test
|
||||||
|
popis: Reading, listening, grammar and a short piece of writing. Level A2.
|
||||||
|
---
|
||||||
129
content/anglictina/1-rocnik/08-test-1-rocniku.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
---
|
||||||
|
id: anglictina-1-test-1-rocniku
|
||||||
|
puvodni: en.1.8
|
||||||
|
nazev: Test 1. ročníku
|
||||||
|
popis: Čtení, poslech, gramatika a krátké psaní. Úroveň A2.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Výklad
|
||||||
|
|
||||||
|
## A2 v jedné sadě
|
||||||
|
*Co test bere*
|
||||||
|
|
||||||
|
- Čtení: najděte v textu čas, místo a důvod, nehádejte z jednoho slova.
|
||||||
|
- Poslech: jména a čísla si poznamenejte hned.
|
||||||
|
- Gramatika: be, přítomné časy, some/any, minulý čas, will a going to.
|
||||||
|
- Psaní: krátký souvislý text, ne jen odrážky.
|
||||||
|
|
||||||
|
> Tip: nejdřív otázky, pak text nebo nahrávku. U psaní počítejte slova.
|
||||||
|
|
||||||
|
## Nejčastější čechismy
|
||||||
|
*Jak opravovat*
|
||||||
|
|
||||||
|
- I am 16, ne I have 16 years.
|
||||||
|
- She likes, ne she like.
|
||||||
|
- Did you went je špatně. Did you go.
|
||||||
|
- There is a book, ne It is a book on the table, když věc uvádíte.
|
||||||
|
|
||||||
|
> Tip: po kontrole si přečtěte nápovědy, i když je věta správně.
|
||||||
|
|
||||||
|
|
||||||
|
# Čtení
|
||||||
|
|
||||||
|
Klara lives in a flat with her parents and a small dog. On weekdays she walks to school because it is only ten minutes away. She loves art. Last Friday her class visited a gallery and she bought a poster. This Saturday she is meeting her cousin at the station. They are going to a concert in the evening.
|
||||||
|
|
||||||
|
## How does Klara get to school?
|
||||||
|
- By tram
|
||||||
|
- [x] She walks
|
||||||
|
- Her dad drives
|
||||||
|
- By train
|
||||||
|
> she walks to school.
|
||||||
|
|
||||||
|
## What did she buy at the gallery?
|
||||||
|
- A dog
|
||||||
|
- A ticket
|
||||||
|
- [x] A poster
|
||||||
|
- A flat
|
||||||
|
> she bought a poster.
|
||||||
|
|
||||||
|
## Who is she meeting on Saturday?
|
||||||
|
- Her parents
|
||||||
|
- Her art teacher
|
||||||
|
- [x] Her cousin
|
||||||
|
- The singer
|
||||||
|
> meeting her cousin.
|
||||||
|
|
||||||
|
|
||||||
|
# Poslech
|
||||||
|
|
||||||
|
Hi, it's Marek. I missed you after school. I'm at the sports centre. The match starts at four, not at three, so don't hurry. Can you bring my blue jacket? It's on the chair in the kitchen. See you there.
|
||||||
|
|
||||||
|
## Where is Marek?
|
||||||
|
- At school
|
||||||
|
- [x] At the sports centre
|
||||||
|
- In the kitchen
|
||||||
|
- On the tram
|
||||||
|
> at the sports centre.
|
||||||
|
|
||||||
|
## What time does the match start?
|
||||||
|
- At three
|
||||||
|
- [x] At four
|
||||||
|
- At five
|
||||||
|
- At ten
|
||||||
|
> starts at four, not at three.
|
||||||
|
|
||||||
|
## What should his friend bring?
|
||||||
|
- A ticket
|
||||||
|
- [x] A blue jacket
|
||||||
|
- Lunch
|
||||||
|
- A poster
|
||||||
|
> bring my blue jacket.
|
||||||
|
|
||||||
|
|
||||||
|
# Psaní
|
||||||
|
|
||||||
|
Write at least 30 words about your family. Use is or are, and the words family and because.
|
||||||
|
|
||||||
|
## Vzorová odpověď
|
||||||
|
|
||||||
|
My family is not very big. I live with my parents and my younger sister. She is twelve and she is funny. We get on well because we both like films. My dad is a driver and my mum is a nurse. I am happy at home.
|
||||||
|
|
||||||
|
## Musí obsahovat
|
||||||
|
|
||||||
|
- family
|
||||||
|
- because
|
||||||
|
|
||||||
|
## Minimum slov
|
||||||
|
|
||||||
|
30
|
||||||
|
|
||||||
|
|
||||||
|
# Kvíz
|
||||||
|
|
||||||
|
## She ___ coffee.
|
||||||
|
- don't like
|
||||||
|
- [x] doesn't like
|
||||||
|
- not like
|
||||||
|
- isn't like
|
||||||
|
> 3. osoba: doesn't like.
|
||||||
|
|
||||||
|
## There ___ any milk.
|
||||||
|
- [x] isn't
|
||||||
|
- aren't
|
||||||
|
- hasn't
|
||||||
|
- don't
|
||||||
|
> Milk je jednotné: there isn't.
|
||||||
|
|
||||||
|
## Yesterday we ___ to the lake.
|
||||||
|
- go
|
||||||
|
- [x] went
|
||||||
|
- gone
|
||||||
|
- going
|
||||||
|
> Minulý tvar go je went.
|
||||||
|
|
||||||
|
## I've got the tickets. We ___ leave at noon.
|
||||||
|
- will
|
||||||
|
- [x] are going to
|
||||||
|
- did
|
||||||
|
- are
|
||||||
|
> Plán s důkazem: going to.
|
||||||
4
content/anglictina/2-rocnik/01-na-cestach.en.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
nazev: On the road
|
||||||
|
popis: Comparison and the past continuous. Gaps and a quiz.
|
||||||
|
---
|
||||||