7 Commits

Author SHA1 Message Date
Sebastian Molenda 1385b3ace0 0.2.3-t7
Deploy to FTP / deploy (push) Successful in 4s
Build APK / build (push) Failing after 1m57s
2026-05-29 22:20:33 +02:00
Sebastian Molenda 66a028880c 0.2.3-t5
Deploy to FTP / deploy (push) Successful in 5s
Build APK / build (push) Failing after 1m59s
2026-05-27 21:45:21 +02:00
Sebastian Molenda 365b12b0eb 0.2.3-t4
Deploy to FTP / deploy (push) Successful in 4s
Build APK / build (push) Failing after 1m56s
Release APK on Tag (Gitea Actions) / build-and-release (push) Failing after 1s
2026-05-27 21:38:16 +02:00
Sebastian Molenda 5ef6747e4d 0.2.3-t3
Deploy to FTP / deploy (push) Successful in 4s
Release APK on Tag (Gitea Actions) / build-and-release (push) Failing after 2s
Build APK / build (push) Failing after 1m57s
2026-05-27 21:21:49 +02:00
Sebastian Molenda 7ce23309f6 0.2.3-t2
Deploy to FTP / deploy (push) Successful in 5s
Build APK / build (push) Successful in 2m1s
Release APK on Tag (Gitea Actions) / build-and-release (push) Failing after 2s
2026-05-27 15:04:28 +02:00
Sebastian Molenda 6c4b5f4adf 0.2.3
Deploy to FTP / deploy (push) Successful in 5s
Build APK / build (push) Successful in 1m58s
2026-05-27 14:57:44 +02:00
Sebastian Molenda b8b7b3860b 0.2.2
Deploy to FTP / deploy (push) Successful in 16s
Build APK / build (push) Successful in 2m8s
2026-05-27 14:21:02 +02:00
84 changed files with 434 additions and 141 deletions
+97 -10
View File
@@ -30,14 +30,101 @@ jobs:
- name: Build APK - name: Build APK
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Upload APK - name: Locate APK
uses: actions/upload-artifact@v4 id: locate_apk
with: run: |
name: app-release set -e
path: app/build/outputs/apk/release/app-release.apk APK_PATH=$(ls app/build/outputs/apk/release/*.apk | head -n1 || true)
if [ -z "$APK_PATH" ]; then
echo "No APK found in app/build/outputs/apk/release" >&2
exit 1
fi
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
echo "Found $APK_PATH"
- name: Upload APK # Note: uploading artifacts with actions/upload-artifact@v4 is not supported
uses: actions/upload-artifact@v4 # on some self-hosted/enterprise runners (GHES). We skip storing artifacts
with: # via that action and instead upload the APK directly to the Gitea release
name: app-release # in the steps below.
path: app/build/outputs/apk/release/app-release.apk
- name: Create Gitea release
id: create_release
env:
GITEA_TOKEN: ${{ secrets.GIT_TOKEN }}
GITEA_SERVER: ${{ secrets.GIT_SERVER }}
run: |
set -e
API_BASE="${GITEA_SERVER}/api/v1"
REMOTE_URL=$(git config --get remote.origin.url)
echo "Remote URL: $REMOTE_URL"
REPO_FULL=$(echo "$REMOTE_URL" \
| sed -E 's#https?://[^/]+/##' \
| sed 's/\.git$//')
OWNER=$(echo "$REPO_FULL" | cut -d/ -f1)
REPO_NAME=$(echo "$REPO_FULL" | cut -d/ -f2)
echo "OWNER=$OWNER"
echo "REPO_NAME=$REPO_NAME"
TAG="${GITEA_REF_NAME}"
echo "Creating release $TAG for $OWNER/$REPO_NAME"
CREATE_RESPONSE=$(curl -s \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-X POST \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$TAG\",
\"body\": \"Automated release for $TAG\",
\"draft\": false,
\"prerelease\": false
}" \
"$API_BASE/repos/$OWNER/$REPO_NAME/releases")
echo "$CREATE_RESPONSE"
RELEASE_ID=$(echo "$CREATE_RESPONSE" \
| grep -o '"id":[0-9]*' \
| head -n1 \
| cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release"
exit 1
fi
echo "RELEASE_ID=$RELEASE_ID" >> $GITEA_ENV
- name: Upload APK to Gitea release
env:
GITEA_TOKEN: ${{ secrets.GIT_TOKEN }}
GITEA_SERVER: ${{ secrets.GIT_SERVER }}
run: |
set -e
API_BASE=${GITEA_SERVER:+$GITEA_SERVER/api/v1}
API_BASE=${API_BASE:-https://gitea.com/api/v1}
REMOTE_URL=$(git config --get remote.origin.url || true)
REPO_FULL=$(echo "$REMOTE_URL" | sed -E 's#.*[:/](.+/.+)\.git$#\1#')
OWNER=$(echo "$REPO_FULL" | cut -d/ -f1)
REPO_NAME=$(echo "$REPO_FULL" | cut -d/ -f2)
APK_PATH=${APK_PATH:-$APK_PATH}
if [ -z "$APK_PATH" ]; then
echo "APK_PATH not set" >&2
exit 1
fi
RELEASE_ID=${RELEASE_ID}
if [ -z "$RELEASE_ID" ]; then
echo "RELEASE_ID not set" >&2
exit 1
fi
UPLOAD_URL="$API_BASE/repos/$OWNER/$REPO_NAME/releases/$RELEASE_ID/assets?name=$(basename $APK_PATH)"
echo "Uploading $APK_PATH to $UPLOAD_URL"
curl --fail -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/octet-stream" \
--data-binary @"$APK_PATH" "$UPLOAD_URL"
+36 -1
View File
@@ -1,3 +1,38 @@
.DS_Store .DS_Store
./QuizzyTemplate ./QuizzyTemplate
./QuizzyTemplate/* /.QuizzyTemplate
# Android/Gradle
/.gradle/
/build/
/app/build/
/**/build/
# Gradle wrapper
/gradle/wrapper/gradle-wrapper.jar
# Local configuration
/local.properties
# Keystore
*.jks
# Generated APKs/outputs
**/outputs/
**/apk/**
*.apk
*.ap_
# IntelliJ / Android Studio
.idea/
*.iml
# macOS
.DS_Store
# NPM / Node
node_modules/
# Misc
*.log
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27 -21
View File
@@ -4,14 +4,7 @@
<option name="autoReloadType" value="NONE" /> <option name="autoReloadType" value="NONE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="51538617-7e5b-4e71-9f47-7bda274cf4cc" name="Changes" comment=""> <list default="true" id="51538617-7e5b-4e71-9f47-7bda274cf4cc" name="Changes" comment="" />
<change beforePath="$PROJECT_DIR$/.gradle/8.5/checksums/checksums.lock" beforeDir="false" afterPath="$PROJECT_DIR$/.gradle/8.5/checksums/checksums.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock" beforeDir="false" afterPath="$PROJECT_DIR$/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gradle/8.5/fileHashes/fileHashes.lock" beforeDir="false" afterPath="$PROJECT_DIR$/.gradle/8.5/fileHashes/fileHashes.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gradle/buildOutputCleanup/buildOutputCleanup.lock" beforeDir="false" afterPath="$PROJECT_DIR$/.gradle/buildOutputCleanup/buildOutputCleanup.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/MainActivity.kt" beforeDir="false" afterPath="$PROJECT_DIR$/MainActivity.kt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/build.gradle" beforeDir="false" afterPath="$PROJECT_DIR$/app/build.gradle" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@@ -20,6 +13,18 @@
<component name="ClangdSettings"> <component name="ClangdSettings">
<option name="formatViaClangd" value="false" /> <option name="formatViaClangd" value="false" />
</component> </component>
<component name="ExternalProjectsManager">
<system id="GRADLE">
<state>
<projects_view>
<tree_state>
<expand />
<select />
</tree_state>
</projects_view>
</state>
</system>
</component>
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component> </component>
@@ -32,20 +37,21 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;, "ModuleVcsDetector.initialDetectionPerformed": "true",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.cidr.known.project.marker&quot;: &quot;true&quot;, "RunOnceActivity.cidr.known.project.marker": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;RunOnceActivity.readMode.enableVisualFormatting&quot;: &quot;true&quot;, "RunOnceActivity.readMode.enableVisualFormatting": "true",
&quot;cf.first.check.clang-format&quot;: &quot;false&quot;, "android.gradle.sync.needed": "true",
&quot;cidr.known.project.marker&quot;: &quot;true&quot;, "cf.first.check.clang-format": "false",
&quot;git-widget-placeholder&quot;: &quot;master&quot;, "cidr.known.project.marker": "true",
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;, "git-widget-placeholder": "master",
&quot;last_opened_file_path&quot;: &quot;/Users/aln/Work/Matma&quot; "kotlin-language-version-configured": "true",
"last_opened_file_path": "/Users/aln/Work/Matma"
} }
}</component> }]]></component>
<component name="TaskManager"> <component name="TaskManager">
<task active="true" id="Default" summary="Default task"> <task active="true" id="Default" summary="Default task">
<changelist id="51538617-7e5b-4e71-9f47-7bda274cf4cc" name="Changes" comment="" /> <changelist id="51538617-7e5b-4e71-9f47-7bda274cf4cc" name="Changes" comment="" />
+30
View File
@@ -13,3 +13,33 @@ Pliki
Jak uruchomić Jak uruchomić
Otwórz plik `index.html` w przeglądarce (najlepiej na urządzeniu mobilnym lub w trybie responsywnym). Otwórz plik `index.html` w przeglądarce (najlepiej na urządzeniu mobilnym lub w trybie responsywnym).
Signing a release APK/AAB
------------------------
Aby uniknąć ostrzeżeń "nieznany deweloper" lub instalatora na Androidzie, zbuduj podpisany pakiet APK lub AAB i zainstaluj go zamiast debugowego APK.
1. Utwórz magazyn kluczy (jeśli go nie masz):
```bash
keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my_key_alias
```
2. Skopiuj `gradle.properties.example` do swojego osobistego pliku `~/.gradle/gradle.properties` i uzupełnij wartości (nie commituj prawdziwych haseł do kontroli wersji).
3. Zbuduj podpisany pakiet APK lub AAB:
```bash
./gradlew assembleRelease # podpisany APK (jeśli podpisywanie jest skonfigurowane)
./gradlew bundleRelease # AAB do Sklepu Play
```
4. Zainstaluj APK za pomocą adb:
```bash
adb install -r app/build/outputs/apk/release/app-release.apk
```
Lub przesłać AAB do Google Play (testowanie wewnętrzne) — zalecane w celu najłatwiejszej dystrybucji.
Jeśli chcesz tylko przetestować lokalnie i zobaczyć ostrzeżenie "Nieznane źródła", możesz tymczasowo włączyć instalowanie z nieznanych źródeł na urządzeniu, ale dystrybucja podpisanego wydania jest bezpieczniejszym podejściem.
+29
View File
@@ -14,10 +14,39 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
// Signing configuration for release builds.
// Configure the following properties in either your
// - ~/.gradle/gradle.properties (recommended for secrets), or
// - <project root>/gradle.properties (do NOT commit passwords)
//
// Example properties:
// MYAPP_STORE_FILE=keystores/my-release-key.jks
// MYAPP_STORE_PASSWORD=your_store_password
// MYAPP_KEY_ALIAS=my_key_alias
// MYAPP_KEY_PASSWORD=your_key_password
signingConfigs {
release {
// Only configure signing when the properties are provided.
if (project.hasProperty('MYAPP_STORE_FILE')) {
storeFile file(MYAPP_STORE_FILE)
storePassword MYAPP_STORE_PASSWORD
keyAlias MYAPP_KEY_ALIAS
keyPassword MYAPP_KEY_PASSWORD
}
}
}
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// Apply signing config if available. This keeps debug and CI builds
// working even when signing properties are absent locally.
if (project.hasProperty('MYAPP_STORE_FILE')) {
signingConfig signingConfigs.release
}
// Ensure release builds are not debuggable.
debuggable false
} }
} }
+18 -18
View File
@@ -55,19 +55,19 @@ const statusEl = document.getElementById('status');
// load settings from localStorage // load settings from localStorage
function loadSettings(){ function loadSettings(){
try{ try{
let progressInner = null const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem('matma:settings') : null
if (raw) { if (raw) {
const s = JSON.parse(raw) const s = JSON.parse(raw)
state.settings = Object.assign(state.settings, s) state.settings = Object.assign(state.settings, s)
} }
}catch(e){ console.warn('settings load failed', e) } }catch(e){ console.warn('settings load failed', e) }
// reflect to inputs // reflect to inputs (guard in case some inputs are missing)
settingTimed.value = state.settings.timedSeconds if (settingTimed) settingTimed.value = state.settings.timedSeconds
settingMaxResult.value = state.settings.maxResult if (settingMaxResult) settingMaxResult.value = state.settings.maxResult
settingMaxOperand.value = state.settings.maxOperand if (settingMaxOperand) settingMaxOperand.value = state.settings.maxOperand
settingSessionProblems.value = state.settings.sessionProblems if (settingSessionProblems) settingSessionProblems.value = state.settings.sessionProblems
settingAllowNegative.checked = !!state.settings.allowNegative if (settingAllowNegative) settingAllowNegative.checked = !!state.settings.allowNegative
settingAllowFraction.checked = !!state.settings.allowFraction if (settingAllowFraction) settingAllowFraction.checked = !!state.settings.allowFraction
} }
function saveSettings(){ function saveSettings(){
@@ -145,16 +145,16 @@ function startPlay(){
renderProblem() renderProblem()
if (state.mode === 'timed'){ if (state.mode === 'timed'){
progressInner.style.width = '0%' if (progressInner) progressInner.style.width = '0%'
startTimer(state.settings.timedSeconds) startTimer(state.settings.timedSeconds)
statusEl.textContent = 'Na czas'; if (statusEl) statusEl.textContent = 'Na czas';
timerEl.classList.remove('hidden'); if (timerEl) timerEl.classList.remove('hidden');
} else { } else {
progressInner.style.width = '0%' if (progressInner) progressInner.style.width = '0%'
state.sessionSolved = 0 state.sessionSolved = 0
state.sessionTarget = Math.max(1, state.settings.sessionProblems) state.sessionTarget = Math.max(1, state.settings.sessionProblems)
updateProgress() updateProgress()
timerEl.classList.add('hidden'); if (timerEl) timerEl.classList.add('hidden');
} }
} }
@@ -311,14 +311,14 @@ backspaceBtn.addEventListener('click', ()=>{
function startTimer(seconds){ function startTimer(seconds){
state.timeLeft = seconds state.timeLeft = seconds
timerEl.textContent = state.timeLeft if (timerEl) timerEl.textContent = state.timeLeft
stopTimer(); stopTimer();
state.timerId = setInterval(()=>{ state.timerId = setInterval(()=>{
state.timeLeft -= 1 state.timeLeft -= 1
timerEl.textContent = state.timeLeft if (timerEl) timerEl.textContent = state.timeLeft
const pct = Math.max(0, (state.timeLeft / seconds) * 100) const pct = Math.max(0, (state.timeLeft / seconds) * 100)
progressInner.style.width = pct + '%' if (progressInner) progressInner.style.width = pct + '%'
if (state.timeLeft <= 0) { if (state.timeLeft <= 0) {
stopTimer() stopTimer()
endSession() endSession()
@@ -358,8 +358,8 @@ summaryBack.addEventListener('click', ()=>{
function updateProgress(){ function updateProgress(){
if (state.mode === 'training'){ if (state.mode === 'training'){
const pct = Math.min(100, Math.round((state.sessionSolved/state.sessionTarget)*100)) const pct = Math.min(100, Math.round((state.sessionSolved/state.sessionTarget)*100))
progressInner.style.width = pct + '%' if (progressInner) progressInner.style.width = pct + '%'
statusEl.textContent = `${state.sessionSolved}/${state.sessionTarget}` if (statusEl) statusEl.textContent = `${state.sessionSolved}/${state.sessionTarget}`
} }
} }
@@ -1,7 +1,20 @@
// Komponenty HTML loader // Komponenty HTML loader
function loadComponent(path, replaceMap = {}) { function loadComponent(path, replaceMap = {}) {
// If path is a full mapped origin used by WebViewAssetLoader, strip it to a relative path.
const mappedOrigin = 'https://appassets.androidplatform.net/assets/';
if (path.startsWith(mappedOrigin)) {
path = path.slice(mappedOrigin.length);
}
// If an absolute path starting with '/' is provided, remove leading slash to make it
// relative to the current document (works under local http server and WebViewAssetLoader).
if (path.startsWith('/')) path = path.slice(1);
return fetch(path) return fetch(path)
.then(r => r.text()) .then(r => {
if (!r.ok) throw new Error('Failed to load component: ' + path);
return r.text();
})
.then(html => { .then(html => {
Object.entries(replaceMap).forEach(([key, val]) => { Object.entries(replaceMap).forEach(([key, val]) => {
html = html.replaceAll(key, val); html = html.replaceAll(key, val);
@@ -83,9 +83,8 @@
} }
// ── Back to list ────────────────────────────────────────────────────────── // ── Back to list ──────────────────────────────────────────────────────────
// Zakończ button: natychmiast wróć do listy bez potwierdzenia
readBackBtn.addEventListener('click', () => { readBackBtn.addEventListener('click', () => {
const active = yOffset > 0 || autoTimer !== null
if (active && !confirm('Wrócić do listy tekstów?')) return
clearInterval(autoTimer) clearInterval(autoTimer)
autoTimer = null autoTimer = null
readWrap.classList.add('hidden') readWrap.classList.add('hidden')
@@ -296,9 +296,8 @@
} }
// ── Navigation ──────────────────────────────────────────────────────────── // ── Navigation ────────────────────────────────────────────────────────────
// Zakończ button: natychmiast wracamy do listy bez potwierdzenia
playBackBtn.addEventListener('click', () => { playBackBtn.addEventListener('click', () => {
const inProgress = blanks.length > 0 && current < blanks.length
if (inProgress && !confirm('Przerwać dyktando i wrócić do listy?')) return
goToList() goToList()
}) })
@@ -136,7 +136,8 @@
let sha = (window.COMMIT_SHA || '').toString().trim() let sha = (window.COMMIT_SHA || '').toString().trim()
if (!sha) { if (!sha) {
try { try {
const res = await fetch('/version.sha', { cache: 'no-cache' }) // use relative path so the request works under WebViewAssetLoader or a local server
const res = await fetch('version.sha', { cache: 'no-cache' })
if (res.ok) { if (res.ok) {
const txt = await res.text() const txt = await res.text()
const first = txt.split(/\r?\n/).find(l => l.trim().length > 0) const first = txt.split(/\r?\n/).find(l => l.trim().length > 0)
@@ -136,7 +136,8 @@
let sha = (window.COMMIT_SHA || '').toString().trim() let sha = (window.COMMIT_SHA || '').toString().trim()
if (!sha) { if (!sha) {
try { try {
const res = await fetch('/version.sha', { cache: 'no-cache' }) // use relative path so the request works under WebViewAssetLoader or a local server
const res = await fetch('version.sha', { cache: 'no-cache' })
if (res.ok) { if (res.ok) {
const txt = await res.text() const txt = await res.text()
const first = txt.split(/\r?\n/).find(l => l.trim().length > 0) const first = txt.split(/\r?\n/).find(l => l.trim().length > 0)
@@ -4,14 +4,10 @@
const backBtn = document.getElementById('back-to-hub') const backBtn = document.getElementById('back-to-hub')
if (!backBtn) return if (!backBtn) return
// Immediately navigate back to hub/menu without confirmation
backBtn.addEventListener('click', (e) => { backBtn.addEventListener('click', (e) => {
const playScreen = document.getElementById('play-screen') const href = backBtn.getAttribute('href')
const taskActive = playScreen && !playScreen.classList.contains('hidden') if (href) window.location.href = href
if (taskActive) {
e.preventDefault()
const ok = confirm('Masz aktywne zadanie. Czy na pewno chcesz wyjść do menu?')
if (ok) window.location.href = backBtn.getAttribute('href')
}
}) })
}) })
})() })()
@@ -1,4 +1,4 @@
#Wed May 27 14:04:13 CEST 2026 #Wed May 27 14:53:22 CEST 2026
base.2=/Users/aln/Work/Matma/app/build/intermediates/dex/debug/mergeProjectDexDebug/6/classes.dex base.2=/Users/aln/Work/Matma/app/build/intermediates/dex/debug/mergeProjectDexDebug/6/classes.dex
path.2=6/classes.dex path.2=6/classes.dex
base.1=/Users/aln/Work/Matma/app/build/intermediates/dex/debug/mergeProjectDexDebug/0/classes.dex base.1=/Users/aln/Work/Matma/app/build/intermediates/dex/debug/mergeProjectDexDebug/0/classes.dex
@@ -1,2 +1,2 @@
3 4
0 0
Binary file not shown.
+54 -31
View File
@@ -9,6 +9,7 @@ const state = {
timeLeft: 60, timeLeft: 60,
sessionSolved: 0, sessionSolved: 0,
sessionTarget: 20, sessionTarget: 20,
history: [],
settings: { settings: {
timedSeconds: 60, timedSeconds: 60,
maxResult: 40, maxResult: 40,
@@ -55,19 +56,19 @@ const statusEl = document.getElementById('status');
// load settings from localStorage // load settings from localStorage
function loadSettings(){ function loadSettings(){
try{ try{
let progressInner = null const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem('matma:settings') : null
if (raw) { if (raw) {
const s = JSON.parse(raw) const s = JSON.parse(raw)
state.settings = Object.assign(state.settings, s) state.settings = Object.assign(state.settings, s)
} }
}catch(e){ console.warn('settings load failed', e) } }catch(e){ console.warn('settings load failed', e) }
// reflect to inputs // reflect to inputs (guard in case some inputs are missing)
settingTimed.value = state.settings.timedSeconds if (settingTimed) settingTimed.value = state.settings.timedSeconds
settingMaxResult.value = state.settings.maxResult if (settingMaxResult) settingMaxResult.value = state.settings.maxResult
settingMaxOperand.value = state.settings.maxOperand if (settingMaxOperand) settingMaxOperand.value = state.settings.maxOperand
settingSessionProblems.value = state.settings.sessionProblems if (settingSessionProblems) settingSessionProblems.value = state.settings.sessionProblems
settingAllowNegative.checked = !!state.settings.allowNegative if (settingAllowNegative) settingAllowNegative.checked = !!state.settings.allowNegative
settingAllowFraction.checked = !!state.settings.allowFraction if (settingAllowFraction) settingAllowFraction.checked = !!state.settings.allowFraction
} }
function saveSettings(){ function saveSettings(){
@@ -137,6 +138,7 @@ function startPlay(){
menuScreen.classList.add('hidden') menuScreen.classList.add('hidden')
playScreen.classList.remove('hidden') playScreen.classList.remove('hidden')
state.score = 0 state.score = 0
state.history = []
scoreEl.textContent = state.score scoreEl.textContent = state.score
state.answerBuffer = '' state.answerBuffer = ''
answerEl.textContent = '' answerEl.textContent = ''
@@ -145,68 +147,89 @@ function startPlay(){
renderProblem() renderProblem()
if (state.mode === 'timed'){ if (state.mode === 'timed'){
progressInner.style.width = '0%' if (progressInner) progressInner.style.width = '0%'
startTimer(state.settings.timedSeconds) startTimer(state.settings.timedSeconds)
statusEl.textContent = 'Na czas'; if (statusEl) statusEl.textContent = 'Na czas';
timerEl.classList.remove('hidden'); if (timerEl) timerEl.classList.remove('hidden');
} else { } else {
progressInner.style.width = '0%' if (progressInner) progressInner.style.width = '0%'
state.sessionSolved = 0 state.sessionSolved = 0
state.sessionTarget = Math.max(1, state.settings.sessionProblems) state.sessionTarget = Math.max(1, state.settings.sessionProblems)
updateProgress() updateProgress()
timerEl.classList.add('hidden'); if (timerEl) timerEl.classList.add('hidden');
} }
} }
function generateProblem(){ function generateProblem(){
const ops = Array.from(state.ops) const ops = Array.from(state.ops)
const op = ops[Math.floor(Math.random()*ops.length)]
const maxOp = Math.max(1, state.settings.maxOperand) const maxOp = Math.max(1, state.settings.maxOperand)
const maxRes = Math.max(1, state.settings.maxResult) const maxRes = Math.max(1, state.settings.maxResult)
let a = 0, b = 0 let a = 0, b = 0, op = '';
for (let i=0;i<200;i++){
const maxAttempts = 200;
for (let i = 0; i < maxAttempts; i++) {
op = ops[Math.floor(Math.random()*ops.length)]
let candidate = null;
if (op === 'div'){ if (op === 'div'){
b = randInt(1, maxOp) b = randInt(1, maxOp)
if (!state.settings.allowFraction){ if (!state.settings.allowFraction){
const maxQByOp = Math.floor(maxOp / b) const maxQByOp = Math.floor(maxOp / b)
const maxQ = Math.min(maxRes, maxQByOp) const maxQ = Math.min(maxRes, maxQByOp)
if (maxQ < 0) continue if (maxQ >= 0) {
const q = randInt(0, maxQ) const q = randInt(0, maxQ)
a = q * b a = q * b
if (state.settings.allowNegative && Math.random() < 0.2) a = -a if (state.settings.allowNegative && Math.random() < 0.2) a = -a
return {a,b,op} candidate = {a,b,op}
}
} else { } else {
const maxResultNByOp = Math.floor((10 * maxOp) / b) const maxResultNByOp = Math.floor((10 * maxOp) / b)
const maxResultN = Math.min(maxRes * 10, maxResultNByOp) const maxResultN = Math.min(maxRes * 10, maxResultNByOp)
if (maxResultN < 0) continue if (maxResultN >= 0) {
const result_n = randInt(0, maxResultN) const result_n = randInt(0, maxResultN)
if ((result_n * b) % 10 !== 0) continue if ((result_n * b) % 10 === 0) {
a = (result_n * b) / 10 a = (result_n * b) / 10
if (state.settings.allowNegative && Math.random() < 0.2) a = -a if (state.settings.allowNegative && Math.random() < 0.2) a = -a
return {a,b,op} candidate = {a,b,op}
}
}
} }
} else if (op === 'mul'){ } else if (op === 'mul'){
a = randInt(0, maxOp) a = randInt(0, maxOp)
b = randInt(0, maxOp) b = randInt(0, maxOp)
if (a * b <= maxRes) return {a,b,op} if (a * b <= maxRes) candidate = {a,b,op}
} else if (op === 'add'){ } else if (op === 'add'){
a = randInt(0, maxOp) a = randInt(0, maxOp)
b = randInt(0, maxOp) b = randInt(0, maxOp)
if (a + b <= maxRes) return {a,b,op} if (a + b <= maxRes) candidate = {a,b,op}
} else if (op === 'sub'){ } else if (op === 'sub'){
a = randInt(0, maxOp) a = randInt(0, maxOp)
b = randInt(0, maxOp) b = randInt(0, maxOp)
if (!state.settings.allowNegative){ if (!state.settings.allowNegative){
if (a < b) [a,b] = [b,a] if (a < b) [a,b] = [b,a]
if (a - b <= maxRes) return {a,b,op} if (a - b <= maxRes) candidate = {a,b,op}
} else { } else {
if (Math.abs(a - b) <= maxRes) return {a,b,op} if (Math.abs(a - b) <= maxRes) candidate = {a,b,op}
}
}
if (candidate) {
// Generujemy klucz dla historii. Dla dodawania i mnożenia 2+3 to to samo co 3+2.
const key = (op === 'add' || op === 'mul')
? `${op}:${[candidate.a, candidate.b].sort().join(',')}`
: `${op}:${candidate.a},${candidate.b}`;
if (!state.history.includes(key) || i === maxAttempts - 1) {
state.history.push(key);
if (state.history.length > 20) state.history.shift();
return candidate;
} }
} }
} }
a = Math.min(maxOp, Math.floor(maxRes/2)) a = Math.min(maxOp, Math.floor(maxRes/2))
b = Math.min(maxOp, 1) b = Math.min(maxOp, 1)
return {a,b,op} return {a,b,op: ops[0] || 'add'}
} }
function randInt(min,max){ function randInt(min,max){
@@ -311,14 +334,14 @@ backspaceBtn.addEventListener('click', ()=>{
function startTimer(seconds){ function startTimer(seconds){
state.timeLeft = seconds state.timeLeft = seconds
timerEl.textContent = state.timeLeft if (timerEl) timerEl.textContent = state.timeLeft
stopTimer(); stopTimer();
state.timerId = setInterval(()=>{ state.timerId = setInterval(()=>{
state.timeLeft -= 1 state.timeLeft -= 1
timerEl.textContent = state.timeLeft if (timerEl) timerEl.textContent = state.timeLeft
const pct = Math.max(0, (state.timeLeft / seconds) * 100) const pct = Math.max(0, (state.timeLeft / seconds) * 100)
progressInner.style.width = pct + '%' if (progressInner) progressInner.style.width = pct + '%'
if (state.timeLeft <= 0) { if (state.timeLeft <= 0) {
stopTimer() stopTimer()
endSession() endSession()
@@ -358,8 +381,8 @@ summaryBack.addEventListener('click', ()=>{
function updateProgress(){ function updateProgress(){
if (state.mode === 'training'){ if (state.mode === 'training'){
const pct = Math.min(100, Math.round((state.sessionSolved/state.sessionTarget)*100)) const pct = Math.min(100, Math.round((state.sessionSolved/state.sessionTarget)*100))
progressInner.style.width = pct + '%' if (progressInner) progressInner.style.width = pct + '%'
statusEl.textContent = `${state.sessionSolved}/${state.sessionTarget}` if (statusEl) statusEl.textContent = `${state.sessionSolved}/${state.sessionTarget}`
} }
} }
+14 -1
View File
@@ -1,7 +1,20 @@
// Komponenty HTML loader // Komponenty HTML loader
function loadComponent(path, replaceMap = {}) { function loadComponent(path, replaceMap = {}) {
// If path is a full mapped origin used by WebViewAssetLoader, strip it to a relative path.
const mappedOrigin = 'https://appassets.androidplatform.net/assets/';
if (path.startsWith(mappedOrigin)) {
path = path.slice(mappedOrigin.length);
}
// If an absolute path starting with '/' is provided, remove leading slash to make it
// relative to the current document (works under local http server and WebViewAssetLoader).
if (path.startsWith('/')) path = path.slice(1);
return fetch(path) return fetch(path)
.then(r => r.text()) .then(r => {
if (!r.ok) throw new Error('Failed to load component: ' + path);
return r.text();
})
.then(html => { .then(html => {
Object.entries(replaceMap).forEach(([key, val]) => { Object.entries(replaceMap).forEach(([key, val]) => {
html = html.replaceAll(key, val); html = html.replaceAll(key, val);
+1 -2
View File
@@ -83,9 +83,8 @@
} }
// ── Back to list ────────────────────────────────────────────────────────── // ── Back to list ──────────────────────────────────────────────────────────
// Zakończ button: natychmiast wróć do listy bez potwierdzenia
readBackBtn.addEventListener('click', () => { readBackBtn.addEventListener('click', () => {
const active = yOffset > 0 || autoTimer !== null
if (active && !confirm('Wrócić do listy tekstów?')) return
clearInterval(autoTimer) clearInterval(autoTimer)
autoTimer = null autoTimer = null
readWrap.classList.add('hidden') readWrap.classList.add('hidden')
+1 -2
View File
@@ -296,9 +296,8 @@
} }
// ── Navigation ──────────────────────────────────────────────────────────── // ── Navigation ────────────────────────────────────────────────────────────
// Zakończ button: natychmiast wracamy do listy bez potwierdzenia
playBackBtn.addEventListener('click', () => { playBackBtn.addEventListener('click', () => {
const inProgress = blanks.length > 0 && current < blanks.length
if (inProgress && !confirm('Przerwać dyktando i wrócić do listy?')) return
goToList() goToList()
}) })
+27 -6
View File
@@ -1,6 +1,6 @@
// Nauka Dzielenia // Nauka Dzielenia
;(function () { ;(function () {
const st = { divisor: null, total: 20, solved: 0, score: 0, current: null, buf: '' } const st = { divisor: null, total: 20, solved: 0, score: 0, current: null, buf: '', history: [] }
const selectScreen = document.getElementById('select-screen') const selectScreen = document.getElementById('select-screen')
const playScreen = document.getElementById('play-screen') const playScreen = document.getElementById('play-screen')
@@ -26,13 +26,14 @@
st.total = Math.max(5, Math.min(100, parseInt(totalInput.value, 10) || 20)) st.total = Math.max(5, Math.min(100, parseInt(totalInput.value, 10) || 20))
st.solved = 0 st.solved = 0
st.score = 0 st.score = 0
st.history = []
show(playScreen) show(playScreen)
nextProblem() nextProblem()
}) })
document.getElementById('back-btn').addEventListener('click', () => show(selectScreen)) document.getElementById('back-btn').addEventListener('click', () => show(selectScreen))
document.getElementById('again-btn').addEventListener('click', () => { document.getElementById('again-btn').addEventListener('click', () => {
st.solved = 0; st.score = 0; show(playScreen); nextProblem() st.solved = 0; st.score = 0; st.history = []; show(playScreen); nextProblem()
}) })
document.getElementById('change-btn').addEventListener('click', () => show(selectScreen)) document.getElementById('change-btn').addEventListener('click', () => show(selectScreen))
@@ -60,9 +61,28 @@
function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min } function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
function nextProblem() { function nextProblem() {
const b = st.divisor || randInt(1, 10) // divisor let a, b, answer, key;
const answer = randInt(1, 10) // quotient (always integer) const maxAttempts = 50;
const a = b * answer // dividend
for (let i = 0; i < maxAttempts; i++) {
b = st.divisor || randInt(1, 10) // divisor
answer = randInt(1, 10) // quotient
a = b * answer // dividend
// Jeśli wybrano konkretny dzielnik, kluczem jest iloraz (wynik).
// W przeciwnym razie kluczem jest całe działanie.
key = st.divisor ? answer : `${a}:${b}`;
if (!st.history.includes(key) || i === maxAttempts - 1) {
st.history.push(key);
break;
}
}
if (st.history.length > (st.divisor ? 6 : 15)) {
st.history.shift();
}
st.current = { a, b, answer } st.current = { a, b, answer }
problemEl.textContent = `${a} ÷ ${b}` problemEl.textContent = `${a} ÷ ${b}`
feedbackEl.textContent = '' feedbackEl.textContent = ''
@@ -136,7 +156,8 @@
let sha = (window.COMMIT_SHA || '').toString().trim() let sha = (window.COMMIT_SHA || '').toString().trim()
if (!sha) { if (!sha) {
try { try {
const res = await fetch('/version.sha', { cache: 'no-cache' }) // use relative path so the request works under WebViewAssetLoader or a local server
const res = await fetch('version.sha', { cache: 'no-cache' })
if (res.ok) { if (res.ok) {
const txt = await res.text() const txt = await res.text()
const first = txt.split(/\r?\n/).find(l => l.trim().length > 0) const first = txt.split(/\r?\n/).find(l => l.trim().length > 0)
+25 -5
View File
@@ -1,6 +1,6 @@
// Nauka Mnożenia // Nauka Mnożenia
;(function () { ;(function () {
const st = { table: null, total: 20, solved: 0, score: 0, current: null, buf: '' } const st = { table: null, total: 20, solved: 0, score: 0, current: null, buf: '', history: [] }
const selectScreen = document.getElementById('select-screen') const selectScreen = document.getElementById('select-screen')
const playScreen = document.getElementById('play-screen') const playScreen = document.getElementById('play-screen')
@@ -27,13 +27,14 @@
st.total = Math.max(5, Math.min(100, parseInt(totalInput.value, 10) || 20)) st.total = Math.max(5, Math.min(100, parseInt(totalInput.value, 10) || 20))
st.solved = 0 st.solved = 0
st.score = 0 st.score = 0
st.history = []
show(playScreen) show(playScreen)
nextProblem() nextProblem()
}) })
document.getElementById('back-btn').addEventListener('click', () => show(selectScreen)) document.getElementById('back-btn').addEventListener('click', () => show(selectScreen))
document.getElementById('again-btn').addEventListener('click', () => { document.getElementById('again-btn').addEventListener('click', () => {
st.solved = 0; st.score = 0; show(playScreen); nextProblem() st.solved = 0; st.score = 0; st.history = []; show(playScreen); nextProblem()
}) })
document.getElementById('change-btn').addEventListener('click', () => show(selectScreen)) document.getElementById('change-btn').addEventListener('click', () => show(selectScreen))
@@ -61,8 +62,26 @@
function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min } function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
function nextProblem() { function nextProblem() {
const a = st.table || randInt(1, 10) let a, b, key;
const b = randInt(1, 10) const maxAttempts = 50;
for (let i = 0; i < maxAttempts; i++) {
a = st.table || randInt(1, 10)
b = randInt(1, 10)
// Jeśli wybrano konkretną tabelkę, kluczem jest tylko drugi składnik.
// Jeśli "wszystkie", kluczem jest posortowana para (żeby 2x3 i 3x2 były traktowane tak samo).
key = st.table ? b : [a, b].sort().join('x');
if (!st.history.includes(key) || i === maxAttempts - 1) {
st.history.push(key);
break;
}
}
if (st.history.length > (st.table ? 6 : 15)) {
st.history.shift();
}
st.current = { a, b, answer: a * b } st.current = { a, b, answer: a * b }
problemEl.textContent = `${a} × ${b}` problemEl.textContent = `${a} × ${b}`
feedbackEl.textContent = '' feedbackEl.textContent = ''
@@ -136,7 +155,8 @@
let sha = (window.COMMIT_SHA || '').toString().trim() let sha = (window.COMMIT_SHA || '').toString().trim()
if (!sha) { if (!sha) {
try { try {
const res = await fetch('/version.sha', { cache: 'no-cache' }) // use relative path so the request works under WebViewAssetLoader or a local server
const res = await fetch('version.sha', { cache: 'no-cache' })
if (res.ok) { if (res.ok) {
const txt = await res.text() const txt = await res.text()
const first = txt.split(/\r?\n/).find(l => l.trim().length > 0) const first = txt.split(/\r?\n/).find(l => l.trim().length > 0)
+3 -7
View File
@@ -4,14 +4,10 @@
const backBtn = document.getElementById('back-to-hub') const backBtn = document.getElementById('back-to-hub')
if (!backBtn) return if (!backBtn) return
// Immediately navigate back to hub/menu without confirmation
backBtn.addEventListener('click', (e) => { backBtn.addEventListener('click', (e) => {
const playScreen = document.getElementById('play-screen') const href = backBtn.getAttribute('href')
const taskActive = playScreen && !playScreen.classList.contains('hidden') if (href) window.location.href = href
if (taskActive) {
e.preventDefault()
const ok = confirm('Masz aktywne zadanie. Czy na pewno chcesz wyjść do menu?')
if (ok) window.location.href = backBtn.getAttribute('href')
}
}) })
}) })
})() })()
@@ -1,9 +1,12 @@
package com.example.app package com.example.app
import android.os.Bundle import android.os.Bundle
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.webkit.WebViewAssetLoader
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -11,16 +14,27 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val webView = WebView(this) val webView = WebView(this)
// Enable remote debugging for WebView so we can inspect JS console via chrome://inspect // Enable remote debugging for WebView so we can inspect JS console via chrome://inspect
WebView.setWebContentsDebuggingEnabled(true) WebView.setWebContentsDebuggingEnabled(true)
webView.settings.javaScriptEnabled = true webView.settings.javaScriptEnabled = true
webView.settings.allowFileAccess = true
webView.settings.domStorageEnabled = true webView.settings.domStorageEnabled = true
webView.webViewClient = WebViewClient() // Use WebViewAssetLoader to serve files from /assets/ over a secure origin.
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this))
.build()
webView.loadUrl("file:///android_asset/index.html") webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if (request == null) return null
return assetLoader.shouldInterceptRequest(request.url)
}
}
// Load the app via the mapped secure origin so fetch() requests are allowed
webView.loadUrl("https://appassets.androidplatform.net/assets/index.html")
setContentView(webView) setContentView(webView)
} }
+12
View File
@@ -0,0 +1,12 @@
# Example properties for signing your release APK/AAB.
# DO NOT commit real passwords. Copy this to gradle.properties in your home
# directory (~/.gradle/gradle.properties) or to a secure CI secret store.
# Path to your keystore file (relative to project root or absolute path)
MYAPP_STORE_FILE=keystores/my-release-key.jks
# Your keystore password
MYAPP_STORE_PASSWORD=
# Your key alias inside the keystore
MYAPP_KEY_ALIAS=
# Your key password (often same as store password)
MYAPP_KEY_PASSWORD=