Compare commits

...

No commits in common. "builds" and "main" have entirely different histories.

1355 changed files with 89148 additions and 264 deletions

3
.gitattributes vendored Normal file
View 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
View 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
View 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/

View 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>

View file

@ -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
View 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
View 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
View 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 |
| ---- | -------- | ------------ |
| ![Úvod](assets/home.png) | ![Němčina](assets/deutsch.png) | ![Neue Freunde](assets/neue-freunde.png) |
### 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 |
| ------- | ------ | ----------- |
| ![Welcome](assets/home.png) | ![German](assets/deutsch.png) | ![Neue Freunde](assets/neue-freunde.png) |
### 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).

View file

@ -1 +0,0 @@
25567891c0ec9f5a74a2fdd7a71ca607155c1826

View file

@ -1 +0,0 @@
25567891c0ec9f5a74a2fdd7a71ca607155c1826

View file

@ -1 +0,0 @@
25567891c0ec9f5a74a2fdd7a71ca607155c1826

View file

@ -1 +0,0 @@
25567891c0ec9f5a74a2fdd7a71ca607155c1826

View file

@ -1 +0,0 @@
25567891c0ec9f5a74a2fdd7a71ca607155c1826

View file

@ -1 +0,0 @@
25567891c0ec9f5a74a2fdd7a71ca607155c1826

View 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
View file

@ -0,0 +1 @@
# Debug-signed student builds keep the full content JSON.

View 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>

View file

@ -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()
}
}

View file

@ -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))
}
}
}

View file

@ -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()
}
}
}

View file

@ -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>

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_bg">#E59F64</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Graduately</string>
</resources>

View 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>

View 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
View 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
}

View 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
View file

@ -0,0 +1,2 @@
-dontwarn org.slf4j.**
-dontnote **

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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
}
}
}
}

View file

@ -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")
}
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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"))
}
}
}

View file

@ -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)
}
}
}
}

View file

@ -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")
}
}

View file

@ -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"))
}
}

View 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

Binary file not shown.

View 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
View 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
View 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 %*

Binary file not shown.

View 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")

View 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) }
}
}
}

View 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)
}

View 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
}
}

View 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
}

View 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())
}
}
}

View 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) } }

View 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
}

View 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,
)
}
}

View file

@ -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()
}

View 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]*?\\*/"),
)
}
}

View 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]) },
)
}
}
}
}

File diff suppressed because it is too large Load diff

View 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)
}
}

File diff suppressed because it is too large Load diff

BIN
assets/app-icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
assets/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

BIN
assets/deutsch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

BIN
assets/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

BIN
assets/neue-freunde.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

319
content/README.md Normal file
View 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.

View file

@ -0,0 +1,4 @@
---
nazev: Me and my family
popis: The verb be, have got and possessives. Gaps and a quiz.
---

View 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.

View file

@ -0,0 +1,4 @@
---
nazev: A school day
popis: The present simple. Reading and gaps.
---

View 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.

View file

@ -0,0 +1,4 @@
---
nazev: A weekend in town
popis: The present continuous for arrangements. Listening.
---

View 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.

View 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 |

View 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.

View file

@ -0,0 +1,4 @@
---
nazev: At the market
popis: Countability, some and any, how much and how many. Reading and gaps.
---

View 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é.

View file

@ -0,0 +1,4 @@
---
nazev: Last summer
popis: The past simple, regular and irregular verbs. Listening and a quiz.
---

View 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.

View file

@ -0,0 +1,4 @@
---
nazev: A message to a friend
popis: Will and going to. A short informal message and gaps.
---

View 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

View file

@ -0,0 +1,4 @@
---
nazev: Year 1 test
popis: Reading, listening, grammar and a short piece of writing. Level A2.
---

View 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.

View file

@ -0,0 +1,4 @@
---
nazev: On the road
popis: Comparison and the past continuous. Gaps and a quiz.
---

Some files were not shown because too many files have changed in this diff Show more