4 Commits

Author SHA1 Message Date
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
82 changed files with 457 additions and 96 deletions
+87 -7
View File
@@ -30,14 +30,94 @@ 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 - name: Upload APK (artifact)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: app-release name: app-release
path: app/build/outputs/apk/release/app-release.apk path: ${{ env.APK_PATH }}
- name: Create Gitea release
id: create_release
env:
GITEA_TOKEN: ${{ secrets.GIT_TOKEN }}
GITEA_SERVER: ${{ secrets.GIT_SERVER }}
run: |
set -e
if [ -n "$GITEA_SERVER" ]; then
API_BASE="$GITEA_SERVER/api/v1"
else
API_BASE="https://gitea.com/api/v1"
fi
REMOTE_URL=$(git config --get remote.origin.url || true)
if [ -z "$REMOTE_URL" ]; then
echo "Cannot determine remote origin URL" >&2
exit 1
fi
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)
TAG=${GITHUB_REF_NAME:-$(echo $GITHUB_REF | sed 's#refs/tags/##')}
if [ -z "$TAG" ]; then
TAG=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$TAG" ]; then
echo "Cannot determine tag name" >&2
exit 1
fi
echo "Creating release $TAG for $OWNER/$REPO_NAME against $API_BASE"
CREATE_RESPONSE=$(curl -s -H "Content-Type: application/json" -H "Authorization: token $GITEA_TOKEN" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"Automated release for $TAG\", \"draft\": false, \"prerelease\": false}" \
"$API_BASE/repos/$OWNER/$REPO_NAME/releases")
RELEASE_ID=$(echo "$CREATE_RESPONSE" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2 | tr -d ' ')
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release. Response: $CREATE_RESPONSE" >&2
exit 1
fi
echo "RELEASE_ID=$RELEASE_ID" >> $GITHUB_ENV
echo "Created release id $RELEASE_ID"
- 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"
+117
View File
@@ -0,0 +1,117 @@
name: Release APK on Tag (Gitea Actions)
on:
push:
tags:
- '*'
# Note: This workflow requires a repository secret named GITEA_TOKEN with a personal access token
# that has `repo` (or appropriate) scope for creating releases and uploading assets.
# Optionally set GITEA_SERVER to your Gitea server base URL (e.g. https://gitea.example.com).p
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Ensure JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Build release APK
run: |
set -e
./gradlew :app:assembleRelease --no-daemon --stacktrace
- name: Locate APK
id: locate_apk
run: |
set -e
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"
exit 1
fi
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
echo "Found $APK_PATH"
- name: Create Gitea release
id: create_release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER: ${{ secrets.GITEA_SERVER }}
run: |
set -e
# Determine API base URL
if [ -n "$GITEA_SERVER" ]; then
API_BASE="$GITEA_SERVER/api/v1"
else
API_BASE="https://gitea.com/api/v1"
fi
# Derive owner/repo from git remote
REMOTE_URL=$(git config --get remote.origin.url || true)
if [ -z "$REMOTE_URL" ]; then
echo "Cannot determine remote origin URL" >&2
exit 1
fi
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)
TAG=${GITHUB_REF_NAME:-$(echo $GITHUB_REF | sed 's#refs/tags/##')}
if [ -z "$TAG" ]; then
# fallback: use git to get tag from HEAD
TAG=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$TAG" ]; then
echo "Cannot determine tag name" >&2
exit 1
fi
echo "Creating release $TAG for $OWNER/$REPO_NAME against $API_BASE"
CREATE_RESPONSE=$(curl -s -H "Content-Type: application/json" -H "Authorization: token $GITEA_TOKEN" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"Automated release for $TAG\", \"draft\": false, \"prerelease\": false}" \
"$API_BASE/repos/$OWNER/$REPO_NAME/releases")
RELEASE_ID=$(echo "$CREATE_RESPONSE" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2 | tr -d ' ')
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release. Response: $CREATE_RESPONSE" >&2
exit 1
fi
echo "RELEASE_ID=$RELEASE_ID" >> $GITHUB_ENV
echo "Created release id $RELEASE_ID"
- name: Upload APK to Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER: ${{ secrets.GITEA_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"
+68
View File
@@ -0,0 +1,68 @@
name: Release APK on Tag
on:
push:
tags:
- '*'
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build release APK
run: ./gradlew :app:assembleRelease --no-daemon --stacktrace
- name: Find APK
id: find_apk
run: |
set -e
APK=$(ls app/build/outputs/apk/release/*.apk | head -n1 || true)
if [ -z "$APK" ]; then
echo "No APK found in app/build/outputs/apk/release"
exit 1
fi
echo "APK_PATH=$APK" >> $GITHUB_ENV
echo "apk=$APK"
- name: Upload artifact (for debugging)
uses: actions/upload-artifact@v4
with:
name: app-release-apk
path: ${{ env.APK_PATH }}
- name: Create GitHub Release
id: create_release
uses: actions/create-release@v1
with:
tag_name: ${{ github.ref_name }}
release_name: Release ${{ github.ref_name }}
body: Automated release for tag ${{ github.ref_name }}
draft: false
prerelease: false
- name: Upload APK to Release
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ env.APK_PATH }}
asset_name: app-release.apk
asset_content_type: application/vnd.android.package-archive
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.
+1 -8
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" />
+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.
+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}`
} }
} }
+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()
}) })
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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)
+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=