feat: add sound effects, improved stroke recognition, instant locale switching with scramble text animation
Release Build / build-docker (push) Successful in 1m9s
Release Build / build-android-and-release (push) Successful in 7m14s

- Add SoundManager with Web Audio API synthesized sounds (stroke error, drawing complete, session complete)
- Add sound effects toggle in settings (persisted via localStorage)
- Improve stroke recognition: curvature similarity, max point deviation, stroke length ratio checks
- Loosen thresholds for long strokes and first-stroke positioning
- Implement instant locale switching (no save required)
- Add ScrambleText component with character-by-character morphing animation
- Apply ScrambleText globally across all views, widgets, and dialogs
- Add i18n keys for sound effects settings (EN/DE/JA)
This commit is contained in:
2026-04-18 22:19:36 +02:00
parent 979f8fc5e1
commit 049eaa1807
18 changed files with 907 additions and 166 deletions
@@ -14,8 +14,10 @@
div
.text-subtitle-1.font-weight-bold.d-flex.align-center.text-white(
style="line-height: 1.2"
) {{ title }}
.text-caption.text-grey(v-if="subtitle") {{ subtitle }}
)
ScrambleText(:text="title")
.text-caption.text-grey(v-if="subtitle")
ScrambleText(:text="subtitle")
div.d-flex.align-center
slot(name="header-right")
@@ -7,9 +7,11 @@
div
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
.text-caption.text-grey.mt-1
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }}
| {{ accuracy?.correct || 0 }}
ScrambleText(:text="$t('stats.correct')")
span.mx-1 /
| {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
| {{ accuracy?.total || 0 }}
ScrambleText(:text="$t('stats.total')")
v-progress-circular(
:model-value="accuracyPercent"
@@ -3,7 +3,7 @@
.d-flex.align-center.justify-space-between.mb-4
.text-subtitle-1.font-weight-bold.d-flex.align-center
v-icon(color="secondary" start size="small") mdi-chart-bar
| {{ $t('stats.srsDistribution') }}
ScrambleText(:text="$t('stats.srsDistribution')")
v-chip.font-weight-bold(
size="x-small"
color="white"
@@ -7,7 +7,7 @@
div(v-for="(count, hour) in forecast" :key="hour")
.d-flex.justify-space-between.align-center.mb-2.py-2.border-b-subtle(v-if="count > 0")
span.text-body-2.text-grey-lighten-1
| {{ hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour) }}
ScrambleText(:text="hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour)")
v-chip.font-weight-bold.text-primary(
size="small"
@@ -15,7 +15,7 @@
) {{ count }}
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
| {{ $t('stats.noIncoming') }}
ScrambleText(:text="$t('stats.noIncoming')")
</template>
<script setup>
@@ -24,7 +24,7 @@ import { computed } from 'vue';
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({
forecast: { type: Object, default: () => ({}) },
forecast: { type: Array, default: () => [] },
});
const hasUpcoming = computed(() => props.forecast && props.forecast.some((c) => c > 0));
@@ -24,7 +24,7 @@
) {{ ghost.accuracy }}%
.text-center.py-2.text-caption.text-grey(v-else)
| {{ $t('stats.noGhosts') }}
ScrambleText(:text="$t('stats.noGhosts')")
</template>
<script setup>
@@ -17,7 +17,8 @@
)
.text-caption.text-medium-emphasis.mt-2.text-right
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
| {{ masteredCount }} / {{ totalItems }}
ScrambleText(:text="$t('stats.items')")
</template>
<script setup>
@@ -33,7 +34,7 @@ const totalItems = computed(
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
);
const masteredCount = computed(
() => (props.distribution || {})[6] || 0,
() => [6, 7, 8, 9, 10].reduce((sum, lvl) => sum + ((props.distribution || {})[lvl] || 0), 0),
);
const masteryPercent = computed(
() => (totalItems.value === 0 ? 0 : Math.round((masteredCount.value / totalItems.value) * 100)),
@@ -5,13 +5,15 @@
)
template(#header-right)
.legend-container
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
span.text-caption.text-medium-emphasis.mr-1
ScrambleText(:text="$t('stats.less')")
.legend-box.level-0
.legend-box.level-1
.legend-box.level-2
.legend-box.level-3
.legend-box.level-4
span.text-caption.text-medium-emphasis.ml-1 {{ $t('stats.more') }}
span.text-caption.text-medium-emphasis.ml-1
ScrambleText(:text="$t('stats.more')")
.heatmap-container
.heatmap-year
@@ -25,7 +27,8 @@
)
.text-center
.font-weight-bold {{ formatDate(day.date) }}
div {{ $t('stats.reviewsCount', { count: day.count }) }}
div
ScrambleText(:text="$t('stats.reviewsCount', { count: day.count })")
</template>
<script setup>
@@ -19,7 +19,8 @@
.d-flex.align-end.mb-3
.text-h3.font-weight-bold.text-white.mr-2(style="line-height: 1")
| {{ streak?.current || 0 }}
.text-body-1.text-grey.mb-1 {{ $t('stats.days') }}
.text-body-1.text-grey.mb-1
ScrambleText(:text="$t('stats.days')")
.d-flex.justify-space-between.align-center.px-1
.d-flex.flex-column.align-center(
@@ -1,9 +1,11 @@
<template lang="pug">
.grid-area-full.text-center.mb-8.zen-wrapper
.text-h3.font-weight-bold.mb-2.text-white.letter-spacing-small {{ $t('hero.welcome') }}
.text-h3.font-weight-bold.mb-2.text-white.letter-spacing-small
ScrambleText(:text="$t('hero.welcome')")
.text-subtitle-1.text-grey-lighten-1.mb-10(
style="max-width: 600px; margin-inline: auto;"
) {{ $t('hero.subtitle') }}
)
ScrambleText(:text="$t('hero.subtitle')")
.d-flex.justify-center.align-stretch.flex-wrap.ga-6.mb-8
@@ -21,9 +23,9 @@
.d-flex.flex-column.align-start.mr-auto
.text-purple-lighten-1.text-h6.font-weight-bold.line-height-tight
| {{ $t('hero.lessons') }}
ScrambleText(:text="$t('hero.lessons')")
.text-purple-accent-1.text-caption.font-weight-regular.opacity-80
| {{ $t('hero.newContent') }}
ScrambleText(:text="$t('hero.newContent')")
.zen-counter.bg-purple-accent-2.text-black.ml-4
| {{ lessonCount }}
@@ -43,9 +45,9 @@
.d-flex.flex-column.align-start.mr-auto
.text-cyan-lighten-1.text-h6.font-weight-bold.line-height-tight
| {{ queueLength > 0 ? $t('hero.start') : $t('hero.noReviews') }}
ScrambleText(:text="queueLength > 0 ? $t('hero.start') : $t('hero.noReviews')")
.text-cyan-accent-1.text-caption.font-weight-regular.opacity-80
| {{ queueLength > 0 ? $t('hero.readyToReview') : $t('hero.allCaughtUp') }}
ScrambleText(:text="queueLength > 0 ? $t('hero.readyToReview') : $t('hero.allCaughtUp')")
v-fade-transition
.zen-counter.bg-cyan-accent-2.text-black.ml-4(v-if="queueLength > 0")
@@ -59,14 +61,16 @@
variant="text"
prepend-icon="mdi-sort-ascending"
:ripple="false"
) {{ $t('hero.prioritize', { count: lowerLevelCount }) }}
)
ScrambleText(:text="$t('hero.prioritize', { count: lowerLevelCount })")
v-fade-transition
.text-caption.text-grey-darken-1.font-weight-medium.d-flex.align-center.mt-2(
v-if="queueLength === 0 && !hasLowerLevels && nextReviewTime"
)
v-icon.mr-2(size="small" icon="mdi-clock-outline")
span {{ $t('hero.nextIn') }} {{ nextReviewTime }}
span
ScrambleText(:text="$t('hero.nextIn') + ' ' + nextReviewTime")
</template>
<script setup>
@@ -79,14 +83,14 @@ const props = defineProps({
lessonCount: { type: Number, default: 0 },
hasLowerLevels: { type: Boolean, default: false },
lowerLevelCount: { type: Number, default: 0 },
forecast: { type: Object, default: () => ({}) },
forecast: { type: Array, default: () => [] },
});
defineEmits(['start']);
const { t } = useI18n();
const nextReviewTime = computed(() => {
if (!props.forecast || Object.keys(props.forecast).length === 0) return null;
if (!props.forecast || props.forecast.length === 0) return null;
try {
const idx = props.forecast.findIndex((c) => c > 0);
if (idx === -1) return null;