Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c0bcdb833 | |||
| 5e9377c681 | |||
| b174e66c8b | |||
| 3394113ea0 | |||
| 1f71846e23 | |||
| 9346117603 | |||
| 2e956aef75 |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -11,7 +11,8 @@ lerna-debug.log*
|
|||||||
currentCommit.txt
|
currentCommit.txt
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist/*
|
||||||
|
!dist/android/
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
@@ -25,3 +26,14 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Capacitor
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
.capacitor/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
output/
|
||||||
|
*.apk
|
||||||
|
!dist/android/*.apk
|
||||||
|
*.aab
|
||||||
282
CHANGELOG.md
282
CHANGELOG.md
@@ -1,282 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
Tutte le modifiche significative a questo progetto sono documentate in questo file.
|
|
||||||
|
|
||||||
Il formato si basa su [Keep a Changelog](https://keepachangelog.com/it/1.0.0/),
|
|
||||||
e questo progetto aderisce al [Versionamento Semantico](https://semver.org/lang/it/).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [1.0.0] - 2026-02-10
|
|
||||||
|
|
||||||
Rilascio iniziale di **Segnapunti Anto**, un'applicazione web Progressive Web App (PWA) professionale per il tracciamento in tempo reale dei punteggi durante partite di pallavolo.
|
|
||||||
|
|
||||||
### Funzionalità Principali
|
|
||||||
|
|
||||||
#### Gestione Partite e Punteggi
|
|
||||||
- **Tracciamento punti in tempo reale** per entrambe le squadre (casa e ospite)
|
|
||||||
- **Conteggio automatico dei set** con supporto per modalità al meglio di 3 o al meglio di 5
|
|
||||||
- **Logica regolamentare completa**:
|
|
||||||
- Set regolari: primo a 25 punti con almeno 2 di vantaggio
|
|
||||||
- Set decisivo (tie-break): primo a 15 punti con almeno 2 di vantaggio
|
|
||||||
- Blocco automatico assegnazione punti al raggiungimento della vittoria
|
|
||||||
- **Indicatore visivo del servizio** per identificare quale squadra è al servizio
|
|
||||||
- **Cronologia punti** con striscia visiva per seguire l'andamento della partita
|
|
||||||
- Possibilità di **incrementare e decrementare punti** e **set**
|
|
||||||
- **Annullamento punti** con ripristino automatico del servizio precedente
|
|
||||||
|
|
||||||
#### Formazioni e Rotazioni
|
|
||||||
- **Visualizzazione interattiva** della formazione in campo con 6 giocatori per squadra
|
|
||||||
- **Rotazione automatica regolamentare** quando la squadra conquista il servizio (cambio palla)
|
|
||||||
- **Configurazione manuale dei numeri di maglia** per ogni giocatore
|
|
||||||
- **Sistema di cambi giocatori**:
|
|
||||||
- Dialog dedicato per effettuare i cambi
|
|
||||||
- Supporto per cambi singoli o multipli
|
|
||||||
- Tabella IN/OUT con validazione degli input
|
|
||||||
- Verifica che i numeri di maglia siano numerici
|
|
||||||
- Scorciatoie da tastiera dedicate per squadra casa e ospite
|
|
||||||
- **Limitazione cambio palla manuale** solo a inizio set (0-0) per prevenire errori nella rotazione
|
|
||||||
|
|
||||||
#### Interfaccia Utente e Personalizzazione
|
|
||||||
- **Interfaccia fullscreen touch-friendly** ottimizzata per tablet e smartphone
|
|
||||||
- **Layout responsive** con media queries per schermi piccoli (<768px)
|
|
||||||
- **Modalità di visualizzazione**:
|
|
||||||
- Punteggio semplice
|
|
||||||
- Formazioni complete con posizioni giocatori
|
|
||||||
- Toggle tra le due modalità con scorciatoia da tastiera
|
|
||||||
- **Personalizzazione squadre**:
|
|
||||||
- Configurazione dinamica dei nomi squadre
|
|
||||||
- Inversione layout orizzontale (scambio posizione casa/ospite)
|
|
||||||
- Configurazione numeri di maglia giocatori
|
|
||||||
- **Controlli nascondibili**: possibilità di nascondere/mostrare barra pulsanti e cronologia
|
|
||||||
- **Stabilizzazione UI**: dimensioni fisse per riquadri punteggio per evitare spostamenti durante gli aggiornamenti
|
|
||||||
|
|
||||||
#### Controlli e Accessibilità
|
|
||||||
- **Controlli da tastiera completi** con scorciatoie dedicate:
|
|
||||||
- **Squadra Casa**: `Ctrl + ↑/↓` (punti), `Ctrl + →` (set), `Ctrl + C` (cambi)
|
|
||||||
- **Squadra Ospite**: `Shift + ↑/↓` (punti), `Shift + →` (set), `Shift + C` (cambi)
|
|
||||||
- **Comandi globali**:
|
|
||||||
- `Ctrl + ←` (cambio palla, solo a 0-0)
|
|
||||||
- `Ctrl + M` (configurazione)
|
|
||||||
- `Ctrl + B` (toggle barra pulsanti)
|
|
||||||
- `Ctrl + F` (fullscreen)
|
|
||||||
- `Ctrl + S` (annuncio vocale)
|
|
||||||
- `Ctrl + Z` (switch visualizzazione)
|
|
||||||
- **Sintesi vocale** per annunci punteggio in italiano usando Web Speech API
|
|
||||||
- **Sistema di alert professionale** usando Wave UI per notifiche e conferme
|
|
||||||
|
|
||||||
#### Progressive Web App (PWA)
|
|
||||||
- **Installabile** come app nativa su smartphone, tablet e desktop
|
|
||||||
- **Funzionamento offline completo** grazie ai Service Worker
|
|
||||||
- **Auto-update automatico** per ricevere nuovi aggiornamenti senza reinstallazione
|
|
||||||
- **Display fullscreen** per massimizzare lo spazio visivo
|
|
||||||
- **Orientamento landscape ottimizzato** per utilizzo su tablet
|
|
||||||
- **Orientamento sensor landscape** con supporto rotazione 180°
|
|
||||||
- **Icone PWA personalizzate** (192x192 e 512x512)
|
|
||||||
- **Prevenzione scroll indesiderato** su mobile (overscroll-behavior)
|
|
||||||
- **Supporto 100dvh** e position:fixed per layout mobile stabile
|
|
||||||
- **Blocco scroll** per evitare ricariche accidentali con swipe-down
|
|
||||||
|
|
||||||
#### Build e Deployment
|
|
||||||
- **Build APK Android** tramite Capacitor per distribuzione nativa
|
|
||||||
- **Setup automatico icone Android** con script dedicato per multi-densità (ldpi, mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi)
|
|
||||||
- **Configurazione Capacitor** ottimizzata per landscape senza splash screen
|
|
||||||
- **Output unificato** in `/dist/android` per build Android
|
|
||||||
- **Base path configurabile** (`/segnap`) per deployment su sottocartelle
|
|
||||||
|
|
||||||
### Tecnologie e Dipendenze
|
|
||||||
|
|
||||||
#### Stack Tecnologico
|
|
||||||
- **Vue 3.4.38** - Framework JavaScript reattivo
|
|
||||||
- **Vite 5.4.10** - Build tool e dev server veloce
|
|
||||||
- **Wave UI 3.17.0** - Libreria UI components per alert e dialogs
|
|
||||||
- **NoSleep.js 0.12.0** - Prevenzione standby durante le partite
|
|
||||||
- **vite-plugin-pwa 0.20.5** - Plugin per generazione PWA
|
|
||||||
- **Capacitor 6.2.0** - Framework per build app native Android/iOS
|
|
||||||
|
|
||||||
#### Ambiente di Sviluppo
|
|
||||||
- **Node.js 20.x LTS** (consigliato v20.2.0)
|
|
||||||
- **npm 9.0.0+** per gestione dipendenze
|
|
||||||
- **NVM support** per gestione versioni Node.js
|
|
||||||
- **Hot Module Replacement (HMR)** in modalità sviluppo
|
|
||||||
- **Source maps** per debugging
|
|
||||||
- **Vue DevTools** supportato
|
|
||||||
|
|
||||||
#### Browser Supportati
|
|
||||||
- **Chrome/Chromium 90+** - Supporto completo (consigliato)
|
|
||||||
- **Firefox 88+** - Supporto completo
|
|
||||||
|
|
||||||
### Build e Comandi
|
|
||||||
|
|
||||||
#### Comandi Disponibili
|
|
||||||
- `npm run dev` - Server di sviluppo con hot-reload su http://localhost:5173
|
|
||||||
- `npm run build` - Build di produzione ottimizzata in `/dist`
|
|
||||||
- `npm run preview` - Anteprima locale della build di produzione
|
|
||||||
|
|
||||||
#### Output Build
|
|
||||||
- File statici ottimizzati e minificati
|
|
||||||
- Service Worker generato automaticamente
|
|
||||||
- PWA manifest configurato
|
|
||||||
- Assets con hash per cache busting
|
|
||||||
- Permessi automatici per cartella dist in ambiente Docker
|
|
||||||
|
|
||||||
### Documentazione
|
|
||||||
- **README.md completo** con:
|
|
||||||
- Panoramica funzionalità
|
|
||||||
- Requisiti di sistema dettagliati
|
|
||||||
- Istruzioni di installazione e setup con NVM
|
|
||||||
- Guida ai comandi per sviluppo e build
|
|
||||||
- Tabella completa shortcuts tastiera
|
|
||||||
- Configurazione PWA documentata
|
|
||||||
- Spiegazione logica regolamentare pallavolo
|
|
||||||
- Browser testati e supportati
|
|
||||||
- **Encoding corretto** per caratteri accentati italiani
|
|
||||||
|
|
||||||
### Miglioramenti Architetturali
|
|
||||||
- **Separazione componenti**: HomePage estratta in componente dedicato
|
|
||||||
- **Rimozione codice legacy**: eliminati file non utilizzati (HelloWorld.vue)
|
|
||||||
- **Refactoring CSS**: file style.css organizzato e ottimizzato
|
|
||||||
- **Gestione servizio migliorata**: variabile booleana `servHome` per gestione cambio servizio
|
|
||||||
- **Validazione input**: controlli su numeri di maglia e cambi giocatori
|
|
||||||
- **Gestione errori**: prevenzione incrementi a set concluso senza notifiche spam
|
|
||||||
|
|
||||||
### Note di Sviluppo
|
|
||||||
- **Orientamento sensor landscape**: permette rotazione 180° del dispositivo
|
|
||||||
- **Fix tentati audio mobile**: implementati ma richiedono ancora debug (WIP)
|
|
||||||
- Aggiunto supporto @capacitor-community/text-to-speech per audio nativo
|
|
||||||
- Implementato fallback Web API su desktop, plugin nativo su mobile
|
|
||||||
- Correzione lingua da 'it_IT' a 'it-IT'
|
|
||||||
- **Java 17 → 21**: aggiornamento per compatibilità plugin TTS
|
|
||||||
- **ImageMagick in Dockerfile**: per generazione automatica icone Android
|
|
||||||
|
|
||||||
### Configurazione
|
|
||||||
- **Base path**: `/segnap` (configurabile in vite.config.js)
|
|
||||||
- **Tema PWA**: background `#eee`, theme color `#ffffff`
|
|
||||||
- **Display**: fullscreen landscape
|
|
||||||
- **Manifest name**: app_segnap
|
|
||||||
- **Short name**: segnap
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architettura e Funzionamento Interno
|
|
||||||
|
|
||||||
### Come Funziona l'Applicazione Web
|
|
||||||
|
|
||||||
L'applicazione è una **Single Page Application (SPA)** completamente **client-side**:
|
|
||||||
|
|
||||||
- **Server**: Serve solo file statici (nessun backend, nessun database)
|
|
||||||
- **Esecuzione**: Tutto gira nel browser dell'utente
|
|
||||||
- **Stato**: Memorizzato solo nella RAM del browser (si perde al refresh)
|
|
||||||
- **Offline**: Funziona completamente offline dopo la prima visita (Service Worker)
|
|
||||||
|
|
||||||
#### Build e Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Vite compila il codice Vue/JavaScript
|
|
||||||
2. Ottimizza e minifica JavaScript e CSS
|
|
||||||
3. Genera Service Worker e manifest PWA
|
|
||||||
4. Output in `/dist` con file statici pronti
|
|
||||||
|
|
||||||
**Deploy**: Copia `/dist` su web server statico (nginx, Apache, Vercel, Netlify). Non serve Node.js, PHP o database sul server.
|
|
||||||
|
|
||||||
#### Funzionamento Runtime
|
|
||||||
|
|
||||||
**Prima visita:**
|
|
||||||
1. Browser scarica index.html dal server
|
|
||||||
2. Carica bundle JavaScript e CSS
|
|
||||||
3. Vue.js inizializza l'applicazione
|
|
||||||
4. Service Worker cachea tutti gli asset
|
|
||||||
5. App pronta (nessuna ulteriore chiamata al server)
|
|
||||||
|
|
||||||
**Visite successive:**
|
|
||||||
1. Service Worker serve i file dalla cache locale
|
|
||||||
2. App si avvia istantaneamente anche senza internet
|
|
||||||
3. In background verifica se esistono aggiornamenti
|
|
||||||
4. Aggiornamenti applicati al prossimo refresh
|
|
||||||
|
|
||||||
#### Gestione Stato
|
|
||||||
|
|
||||||
Tutto lo stato della partita vive nella memoria del browser:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
sp: {
|
|
||||||
punt: { home: 0, guest: 0 },
|
|
||||||
set: { home: 0, guest: 0 },
|
|
||||||
servHome: true,
|
|
||||||
form: { home: ["1","2","3","4","5","6"], guest: ["1","2","3","4","5","6"] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Limitazioni:**
|
|
||||||
- Refresh della pagina azzera tutto lo stato
|
|
||||||
- Nessuna sincronizzazione tra dispositivi
|
|
||||||
- Nessuno storico partite
|
|
||||||
|
|
||||||
**Vantaggi:**
|
|
||||||
- Privacy totale (nessun dato esce dal dispositivo)
|
|
||||||
- Velocità massima (nessuna latenza di rete)
|
|
||||||
- Funzionamento offline completo
|
|
||||||
|
|
||||||
**Possibili evoluzioni:**
|
|
||||||
- Persistenza con localStorage
|
|
||||||
- Backend con database per multi-dispositivo
|
|
||||||
- WebSocket per sincronizzazione real-time
|
|
||||||
|
|
||||||
### Note Tecniche
|
|
||||||
|
|
||||||
**Problemi noti:**
|
|
||||||
- Audio mobile: sintesi vocale non funziona su alcuni dispositivi Android
|
|
||||||
- Persistenza: lo stato si perde al refresh della pagina
|
|
||||||
- Codice legacy: presenza di file HomePage duplicati
|
|
||||||
|
|
||||||
**Architettura futura:**
|
|
||||||
- Stato centralizzato con Pinia/Vuex
|
|
||||||
- Architettura client-server con WebSocket per display remoto
|
|
||||||
- Persistenza con localStorage o database
|
|
||||||
- Testing con Vitest e Cypress
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Informazioni sul Progetto
|
|
||||||
|
|
||||||
**Nome progetto**: Segnapunti Anto
|
|
||||||
**Descrizione**: Applicazione web PWA per tracciare i punteggi di partite di pallavolo in tempo reale
|
|
||||||
**Sviluppato per**: Team Antoniana
|
|
||||||
**Licenza**: Privata
|
|
||||||
**Repository**: https://github.com/[username]/segnapunti
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Come Leggere Questo Changelog
|
|
||||||
|
|
||||||
- **Funzionalità Principali**: nuove caratteristiche aggiunte all'applicazione
|
|
||||||
- **Tecnologie e Dipendenze**: stack tecnologico e versioni utilizzate
|
|
||||||
- **Build e Comandi**: istruzioni per compilare ed eseguire il progetto
|
|
||||||
- **Documentazione**: miglioramenti alla documentazione utente e sviluppatore
|
|
||||||
- **Miglioramenti Architetturali**: refactoring e ottimizzazioni del codice
|
|
||||||
- **Note di Sviluppo**: work in progress e problemi noti
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Supporto e Contributi
|
|
||||||
|
|
||||||
Per segnalare bug o richiedere funzionalità, aprire una issue nel repository del progetto.
|
|
||||||
|
|
||||||
Per contribuire al progetto:
|
|
||||||
1. Fork del repository
|
|
||||||
2. Creare un branch per la feature (`git checkout -b feature/nome-feature`)
|
|
||||||
3. Commit delle modifiche (`git commit -m 'Aggiunge nome-feature'`)
|
|
||||||
4. Push al branch (`git push origin feature/nome-feature`)
|
|
||||||
5. Aprire una Pull Request
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Data primo commit**: 2024
|
|
||||||
**Data rilascio 1.0.0**: 2026-02-10
|
|
||||||
83
Dockerfile
Normal file
83
Dockerfile
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Dockerfile per build APK Android con Capacitor
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
# Evita prompt interattivi
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Installa dipendenze base
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
unzip \
|
||||||
|
wget \
|
||||||
|
openjdk-21-jdk \
|
||||||
|
build-essential \
|
||||||
|
imagemagick \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Installa Node.js 20.x (LTS)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Installa Gradle 8.5
|
||||||
|
ENV GRADLE_VERSION=8.5
|
||||||
|
RUN wget https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -P /tmp \
|
||||||
|
&& unzip -d /opt/gradle /tmp/gradle-${GRADLE_VERSION}-bin.zip \
|
||||||
|
&& ln -s /opt/gradle/gradle-${GRADLE_VERSION}/bin/gradle /usr/bin/gradle \
|
||||||
|
&& rm /tmp/gradle-${GRADLE_VERSION}-bin.zip
|
||||||
|
|
||||||
|
# Installa Android SDK
|
||||||
|
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
||||||
|
ENV ANDROID_HOME=${ANDROID_SDK_ROOT}
|
||||||
|
ENV PATH=${PATH}:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools
|
||||||
|
|
||||||
|
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
|
||||||
|
&& wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -P /tmp \
|
||||||
|
&& unzip -d ${ANDROID_SDK_ROOT}/cmdline-tools /tmp/commandlinetools-linux-9477386_latest.zip \
|
||||||
|
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
|
||||||
|
&& rm /tmp/commandlinetools-linux-9477386_latest.zip
|
||||||
|
|
||||||
|
# Accetta licenze Android SDK
|
||||||
|
RUN yes | sdkmanager --licenses || true
|
||||||
|
|
||||||
|
# Installa componenti Android necessari
|
||||||
|
RUN sdkmanager "platform-tools" \
|
||||||
|
"platforms;android-34" \
|
||||||
|
"build-tools;34.0.0" \
|
||||||
|
"extras;google;google_play_services" \
|
||||||
|
"extras;android;m2repository" \
|
||||||
|
"extras;google;m2repository"
|
||||||
|
|
||||||
|
# Imposta JAVA_HOME in base all'architettura
|
||||||
|
RUN ARCH=$(dpkg --print-architecture) && \
|
||||||
|
echo "export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-${ARCH}" >> /etc/profile && \
|
||||||
|
ln -sf /usr/lib/jvm/java-21-openjdk-${ARCH} /usr/lib/jvm/default-java
|
||||||
|
|
||||||
|
ENV JAVA_HOME=/usr/lib/jvm/default-java
|
||||||
|
|
||||||
|
# Directory di lavoro
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copia package files per cache layer
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Installa dipendenze npm
|
||||||
|
RUN npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
# Copia il resto del progetto
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build script
|
||||||
|
CMD ["bash", "-c", "\
|
||||||
|
npm install --legacy-peer-deps && \
|
||||||
|
npm run build && \
|
||||||
|
npx cap add android && \
|
||||||
|
npx cap sync && \
|
||||||
|
sed -i 's/android:configChanges=\"\\([^\"]*\\)\"/android:configChanges=\"\\1\" android:screenOrientation=\"sensorLandscape\"/g' android/app/src/main/AndroidManifest.xml && \
|
||||||
|
bash setup-android-icons.sh && \
|
||||||
|
cd android && ./gradlew assembleDebug --no-daemon && \
|
||||||
|
mkdir -p /app/dist/android && \
|
||||||
|
cp app/build/outputs/apk/debug/app-debug.apk /app/dist/android/segnapunti-debug.apk && \
|
||||||
|
echo '' && \
|
||||||
|
echo 'APK generato: /app/dist/android/segnapunti-debug.apk'\
|
||||||
|
"]
|
||||||
546
README.md
546
README.md
@@ -1,255 +1,437 @@
|
|||||||
# Segnapunti Anto
|
# Segnapunti - Anto
|
||||||
|
|
||||||
Applicazione web **Progressive Web App (PWA)** per tracciare i punteggi di partite di pallavolo in tempo reale.
|
Un'applicazione web segnapunti per pallavolo con funzionalità di sintesi vocale, modalità formazione e supporto PWA (Progressive Web App).
|
||||||
|
|
||||||
---
|
## Cos'è
|
||||||
|
|
||||||
## Panoramica
|
Segnapunti è un'applicazione standalone per tenere il punteggio durante le partite di pallavolo. L'applicazione è ottimizzata per dispositivi mobili in modalità landscape (orizzontale) e può essere installata come app nativa grazie al supporto PWA.
|
||||||
|
|
||||||
**Segnapunti Anto** è un'applicazione digitale per il tracciamento dei punteggi durante partite di pallavolo, ottimizzata per l'uso su tablet e smartphone. Sviluppata per il team Antoniana, l'app fornisce un'interfaccia fullscreen touch-friendly con supporto offline e controlli da tastiera.
|
### Caratteristiche principali
|
||||||
|
|
||||||
### Funzionalità Principali
|
- **Segnapunti digitale**: Visualizzazione chiara del punteggio per le due squadre (Home e Guest)
|
||||||
|
- **Contatore set**: Traccia i set vinti da ciascuna squadra (fino a 3 set)
|
||||||
|
- **Indicatore battuta**: Mostra quale squadra ha il servizio
|
||||||
|
- **Modalità formazione**: Visualizza la rotazione dei giocatori in campo (posizioni 1-6)
|
||||||
|
- **Sintesi vocale**: Annuncia il punteggio in italiano
|
||||||
|
- **PWA**: Installabile come app standalone con funzionamento offline
|
||||||
|
- **Controllo da tastiera**: Scorciatoie per gestire rapidamente il punteggio
|
||||||
|
- **No Sleep**: Impedisce allo schermo di spegnersi durante l'uso
|
||||||
|
- **Fullscreen automatico**: Su dispositivi mobili si avvia a schermo intero
|
||||||
|
|
||||||
- **Gestione Completa Partite**
|
## Come funziona
|
||||||
- Tracciamento punti in tempo reale per entrambe le squadre
|
|
||||||
- Conteggio automatico dei set (modalità 2/3 o 3/5)
|
|
||||||
- Indicatore visivo del servizio
|
|
||||||
- Blocco incremento punti a set concluso
|
|
||||||
- Cronologia punti con striscia visiva
|
|
||||||
|
|
||||||
- **Formazioni Squadra**
|
### Interfaccia
|
||||||
- Visualizzazione interattiva dei 6 giocatori in campo
|
|
||||||
- Rotazione automatica regolamentare al cambio palla
|
|
||||||
- Configurazione manuale dei numeri di maglia
|
|
||||||
- Dialog cambi con uno o due cambi (IN → OUT) e validazioni
|
|
||||||
- Supporto logica pallavolo ufficiale (25 punti + 2 di vantaggio, tie-break a 15 nel set decisivo)
|
|
||||||
|
|
||||||
- **Controlli Multimodali**
|
L'applicazione divide lo schermo in due metà:
|
||||||
- Scorciatoie da tastiera complete (vedi sezione [Shortcuts](#shortcuts))
|
|
||||||
- Sintesi vocale per annunci punteggio in italiano (Web Speech API)
|
|
||||||
|
|
||||||
- **Personalizzazione**
|
- **Metà sinistra (gialla su sfondo nero)**: Squadra Home - Antoniana
|
||||||
- Configurazione dinamica nomi squadre
|
- **Metà destra (bianca su sfondo blu)**: Squadra Guest
|
||||||
- Selettore modalità partita: al meglio di 3 o al meglio di 5
|
|
||||||
- Toggle layout orizzontale (inverti home/guest)
|
|
||||||
- Modalità visualizzazione: punteggio semplice o formazioni complete
|
|
||||||
- Nascondi/mostra controlli e cronologia
|
|
||||||
|
|
||||||
---
|
#### Visualizzazioni
|
||||||
|
|
||||||
## Requisiti
|
1. **Modalità Punteggio** (default):
|
||||||
|
- Mostra il punteggio corrente in caratteri molto grandi
|
||||||
|
- Cliccando sul punteggio si incrementa di 1
|
||||||
|
- Cliccando sul nome della squadra si decrementa di 1
|
||||||
|
|
||||||
### Requisiti di Sistema
|
2. **Modalità Formazione**:
|
||||||
|
- Mostra le posizioni dei giocatori in campo (1-6)
|
||||||
|
- La formazione ruota automaticamente quando si incrementa il punteggio
|
||||||
|
- L'ordine visualizzato è: [3, 2, 1, 4, 5, 0] (ordine inverso rispetto alla rotazione standard)
|
||||||
|
|
||||||
#### Per Sviluppo
|
#### Funzionalità
|
||||||
- **Sistema Operativo**: Linux, macOS, Windows (WSL2 consigliato)
|
|
||||||
- **Node.js**: v20.2.0 o superiore (LTS consigliato)
|
|
||||||
- **npm**: v9.0.0 o superiore (incluso con Node.js)
|
|
||||||
- **RAM**: Minimo 2GB, consigliato 4GB
|
|
||||||
- **Spazio Disco**: ~500MB per dipendenze e build
|
|
||||||
|
|
||||||
#### Per Deployment
|
- **Reset**: Azzera punteggio e formazioni, mantiene i nomi delle squadre
|
||||||
- **Server Web**: Qualsiasi server statico (nginx, Apache, Vercel, Netlify)
|
- **Cambio servizio**: Toggle manuale del servizio tra le squadre
|
||||||
- **HTTPS**: Obbligatorio per Service Worker e PWA (eccetto localhost)
|
- **Configurazione nomi**: Dialog per modificare i nomi delle squadre
|
||||||
- **Connessione Internet**: Solo per primo caricamento (poi funziona offline)
|
- **Sintesi vocale**:
|
||||||
|
- Pronuncia il punteggio in italiano
|
||||||
|
- Se 0-0 dice "zero a zero"
|
||||||
|
- Se pari dice "[punteggio] pari"
|
||||||
|
- Altrimenti annuncia prima il punteggio della squadra al servizio
|
||||||
|
- **Incremento set**: Click sul contatore set per aumentarlo (si azzera dopo 2)
|
||||||
|
|
||||||
### Requisiti Browser (Utente Finale)
|
### Controlli da tastiera
|
||||||
|
|
||||||
| Requisito | Dettaglio | Necessità |
|
- **Ctrl + M**: Apri dialog configurazione nomi
|
||||||
|-----------|-----------|-----------|
|
- **Ctrl + B**: Mostra/nascondi barra pulsanti in basso
|
||||||
| **JavaScript ES6+** | Supporto moduli, arrow functions, async/await | Obbligatorio |
|
- **Ctrl + F**: Attiva modalità fullscreen
|
||||||
| **Service Worker API** | Per funzionalità offline PWA | Obbligatorio |
|
- **Ctrl + S**: Pronuncia il punteggio
|
||||||
| **Fullscreen API** | Per modalità schermo intero | Consigliato |
|
- **Ctrl + Z**: Alterna tra modalità punteggio e formazione
|
||||||
| **Web Speech API** | Per sintesi vocale punteggi | Opzionale |
|
- **Ctrl + ↑**: Incrementa punteggio Home
|
||||||
| **Local Storage** | Per persistenza configurazioni | Consigliato |
|
- **Ctrl + ↓**: Decrementa punteggio Home
|
||||||
|
- **Ctrl + →**: Incrementa set Home
|
||||||
|
- **Shift + ↑**: Incrementa punteggio Guest
|
||||||
|
- **Shift + ↓**: Decrementa punteggio Guest
|
||||||
|
- **Shift + →**: Incrementa set Guest
|
||||||
|
- **Ctrl + ←**: Cambia servizio
|
||||||
|
|
||||||
### Browser Testati e Supportati
|
### Comportamento su mobile
|
||||||
|
|
||||||
| Browser | Versione Minima | Supporto | Note |
|
Su dispositivi mobili l'applicazione:
|
||||||
|---------|-----------------|----------|------|
|
1. Attiva automaticamente NoSleep per impedire lo spegnimento dello schermo
|
||||||
| Chrome/Chromium | 90+ | ✅ Completo | Consigliato per tutte le features |
|
2. Si avvia in modalità fullscreen
|
||||||
| Firefox | 88+ | ✅ Completo | Supporto completo PWA e Speech API |
|
3. Esegue un test di sintesi vocale al caricamento
|
||||||
|
4. Mostra il pulsante "Esci" per chiudere l'app
|
||||||
|
|
||||||
---
|
## Stack tecnologico
|
||||||
|
|
||||||
## Installazione e Setup
|
### Framework e librerie
|
||||||
|
|
||||||
|
- **Vue 3** (^3.2.47): Framework JavaScript progressivo per l'interfaccia utente
|
||||||
|
- **Vite** (^4.3.9): Build tool moderno e veloce
|
||||||
|
- **Wave UI** (^3.3.0): Libreria di componenti UI per Vue
|
||||||
|
- **NoSleep.js** (^0.12.0): Previene lo spegnimento automatico dello schermo
|
||||||
|
- **vite-plugin-pwa** (^0.16.0): Plugin per generare la PWA
|
||||||
|
|
||||||
|
### API Web utilizzate
|
||||||
|
|
||||||
|
- **Web Speech API**: Per la sintesi vocale (Text-to-Speech)
|
||||||
|
- **Fullscreen API**: Per la modalità schermo intero
|
||||||
|
- **Service Worker**: Per il funzionamento offline (PWA)
|
||||||
|
|
||||||
|
## Come svilupparlo
|
||||||
|
|
||||||
### Prerequisiti
|
### Prerequisiti
|
||||||
|
|
||||||
- **Node.js** v20.2.0 (consigliato)
|
- **Node.js** (versione 14 o superiore, raccomandata v20.2.0)
|
||||||
- **npm** o **yarn**
|
- **npm** o **yarn**
|
||||||
|
|
||||||
### Installazione con NVM (consigliato)
|
### Installazione
|
||||||
|
|
||||||
|
1. Clona il repository:
|
||||||
|
|
||||||
|
2. Installa le dipendenze:
|
||||||
```bash
|
```bash
|
||||||
# Installa la versione corretta di Node.js
|
|
||||||
nvm install v20.2.0
|
|
||||||
nvm use v20.2.0
|
|
||||||
|
|
||||||
# Clona il repository
|
|
||||||
git clone <repository-url>
|
|
||||||
cd segnapunti
|
|
||||||
|
|
||||||
# Installa le dipendenze
|
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Comandi di sviluppo
|
||||||
|
|
||||||
## Comandi per Sviluppo
|
#### Avviare il server di sviluppo
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Questo comando avvia Vite in modalità development. L'applicazione sarà disponibile su `http://localhost:5173` (o altra porta se già occupata).
|
||||||
|
|
||||||
### Dev Server
|
#### Build per produzione
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
Genera i file ottimizzati per la produzione nella cartella `dist/`. Include:
|
||||||
|
- Minificazione del codice
|
||||||
|
- Tree-shaking per rimuovere codice non utilizzato
|
||||||
|
- Generazione del Service Worker per la PWA
|
||||||
|
- Generazione del manifest per l'installazione
|
||||||
|
|
||||||
Avvia il server di sviluppo con hot-reload:
|
#### Anteprima build di produzione
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
Avvia un server locale per testare la build di produzione.
|
||||||
|
|
||||||
|
La configurazione PWA si trova in [vite.config.js](vite.config.js:10-32):
|
||||||
|
|
||||||
|
- **Display**: fullscreen
|
||||||
|
- **Orientamento**: landscape (orizzontale)
|
||||||
|
- **Colore tema**: bianco
|
||||||
|
- **Base path**: `/segnap` in produzione, `/` in sviluppo
|
||||||
|
|
||||||
|
### Modifiche comuni
|
||||||
|
|
||||||
|
#### Cambiare i colori delle squadre
|
||||||
|
|
||||||
|
Modifica il file [src/style.css](src/style.css:101-108):
|
||||||
|
```css
|
||||||
|
.home {
|
||||||
|
background-color: black;
|
||||||
|
color: yellow;
|
||||||
|
}
|
||||||
|
.guest {
|
||||||
|
background-color: blue;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Modificare i nomi predefiniti
|
||||||
|
|
||||||
|
Nel file [src/components/HomePage.vue](src/components/HomePage.vue:20), modifica:
|
||||||
|
```javascript
|
||||||
|
nomi: { home: "Antoniana", guest: "Guest" }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cambiare la voce della sintesi vocale
|
||||||
|
|
||||||
|
Nel file [src/components/HomePage.vue](src/components/HomePage.vue:104), modifica:
|
||||||
|
```javascript
|
||||||
|
msg.voice = voices.find(voice => voice.name === 'Google italiano');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Modificare l'ordine di visualizzazione delle formazioni
|
||||||
|
|
||||||
|
Nel template del componente [src/components/HomePage.vue](src/components/HomePage.vue:181), l'ordine di visualizzazione delle posizioni è controllato dall'array nell'attributo `v-for`. La sequenza `[3, 2, 1, 4, 5, 0]` rappresenta l'ordine in cui vengono mostrate le posizioni dei giocatori sulla griglia 2x3 del campo.
|
||||||
|
|
||||||
|
Per modificare l'ordine di visualizzazione, modifica questo array:
|
||||||
|
```vue
|
||||||
|
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
|
||||||
|
{{ sp.form.home[x] }}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
L'ordine attuale mostra le posizioni in questo schema:
|
||||||
|
```
|
||||||
|
3 2 1
|
||||||
|
4 5 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Dove 0 corrisponde alla posizione 1 nel pallavolo, 1 alla posizione 2, e così via.
|
||||||
|
|
||||||
|
## Testing e Deploy
|
||||||
|
|
||||||
|
### Testing su dispositivi mobili
|
||||||
|
|
||||||
|
Per testare l'applicazione su dispositivi mobili nella stessa rete locale:
|
||||||
|
|
||||||
|
1. Avvia il server di sviluppo:
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
L'applicazione sarà disponibile su [http://localhost:5173](http://localhost:5173)
|
2. Vite mostrerà l'indirizzo locale e di rete. Se non viene mostrato, trova l'indirizzo IP del tuo computer:
|
||||||
|
```bash
|
||||||
|
# Su Linux/Mac
|
||||||
|
hostname -I
|
||||||
|
# oppure
|
||||||
|
ifconfig | grep "inet "
|
||||||
|
|
||||||
### Modalità Sviluppo
|
# Su Windows
|
||||||
- Hot Module Replacement (HMR) attivo
|
ipconfig
|
||||||
- Source maps per debugging
|
```
|
||||||
- Vue DevTools supportato
|
|
||||||
- Errori e warnings in console
|
|
||||||
|
|
||||||
---
|
3. Dal dispositivo mobile, connesso alla stessa rete Wi-Fi, naviga su:
|
||||||
|
```
|
||||||
|
http://[tuo-ip]:5173
|
||||||
|
```
|
||||||
|
|
||||||
## Comandi per Build
|
4. Accetta eventuali permessi richiesti dal browser per:
|
||||||
|
- Modalità fullscreen
|
||||||
|
- Sintesi vocale
|
||||||
|
- Wake Lock (prevenzione spegnimento schermo)
|
||||||
|
|
||||||
### Build Produzione
|
### Build e Deploy
|
||||||
|
|
||||||
Genera i file ottimizzati per il deployment:
|
#### Build per produzione
|
||||||
|
|
||||||
|
Genera i file ottimizzati per la produzione:
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
**Output:**
|
Questo comando crea una cartella `dist/` contenente:
|
||||||
- Cartella `/dist` con file statici ottimizzati
|
- HTML, CSS e JavaScript minificati
|
||||||
- Service Worker generato automaticamente
|
- Service Worker per il funzionamento offline
|
||||||
- PWA manifest configurato
|
- Manifest PWA per l'installazione come app
|
||||||
- Assets minificati e con hash per cache busting
|
- Asset ottimizzati
|
||||||
- Base path: `/segnap` (modificabile in `vite.config.js`)
|
|
||||||
|
|
||||||
### Preview Build
|
#### Anteprima della build
|
||||||
|
|
||||||
Anteprima locale della build di produzione:
|
|
||||||
|
|
||||||
|
Prima del deploy, puoi testare la build in locale:
|
||||||
```bash
|
```bash
|
||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
Serve i file dalla cartella `/dist` per testare la build prima del deploy.
|
#### Deploy su GitHub Pages
|
||||||
|
|
||||||
---
|
1. Nel file [vite.config.js](vite.config.js:7), verifica che il `base` path corrisponda al nome del repository:
|
||||||
|
```javascript
|
||||||
|
base: process.env.NODE_ENV === 'production' ? '/nome-repository' : '/',
|
||||||
|
```
|
||||||
|
|
||||||
## Shortcuts
|
2. Esegui la build:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
### Controlli Tastiera Squadra Home
|
3. Publica il contenuto della cartella `dist/` su GitHub Pages usando uno di questi metodi:
|
||||||
|
- Manualmente tramite l'interfaccia GitHub
|
||||||
|
- Usando `gh-pages`:
|
||||||
|
```bash
|
||||||
|
npm install -D gh-pages
|
||||||
|
npx gh-pages -d dist
|
||||||
|
```
|
||||||
|
- Tramite GitHub Actions (configurando un workflow)
|
||||||
|
|
||||||
| Scorciatoia | Azione |
|
#### Deploy su server web
|
||||||
|-------------|--------|
|
|
||||||
| `Ctrl + ↑` | Incrementa punti |
|
|
||||||
| `Ctrl + ↓` | Decrementa punti |
|
|
||||||
| `Ctrl + →` | Incrementa set |
|
|
||||||
| `Ctrl + C` | Apri dialog cambi |
|
|
||||||
|
|
||||||
### Controlli Tastiera Squadra Guest
|
Il contenuto della cartella `dist/` può essere servito da qualsiasi server web statico:
|
||||||
|
|
||||||
| Scorciatoia | Azione |
|
**Nginx:**
|
||||||
|-------------|--------|
|
```nginx
|
||||||
| `Shift + ↑` | Incrementa punti |
|
server {
|
||||||
| `Shift + ↓` | Decrementa punti |
|
listen 80;
|
||||||
| `Shift + →` | Incrementa set |
|
server_name tuo-dominio.com;
|
||||||
| `Shift + C` | Apri dialog cambi |
|
root /percorso/a/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
### Comandi Globali
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
| Scorciatoia | Azione |
|
**Apache (.htaccess):**
|
||||||
|-------------|--------|
|
```apache
|
||||||
| `Ctrl + ←` | Cambio palla (servizio) - **solo a 0-0** |
|
<IfModule mod_rewrite.c>
|
||||||
| `Ctrl + M` | Apri configurazione nomi squadre e formazioni |
|
RewriteEngine On
|
||||||
| `Ctrl + B` | Toggle visibilità barra pulsanti |
|
RewriteBase /
|
||||||
| `Ctrl + F` | Attiva/disattiva fullscreen |
|
RewriteRule ^index\.html$ - [L]
|
||||||
| `Ctrl + S` | Annuncio vocale punteggio corrente |
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
| `Ctrl + Z` | Switch tra visualizzazione formazioni e punteggio |
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule . /index.html [L]
|
||||||
|
</IfModule>
|
||||||
|
```
|
||||||
|
|
||||||
---
|
#### Installazione come PWA
|
||||||
|
|
||||||
## Configurazione PWA
|
Dopo il deploy, gli utenti possono installare l'app:
|
||||||
|
|
||||||
L'applicazione è configurata come **Progressive Web App** nel file [vite.config.js](vite.config.js):
|
**Su Android/Chrome:**
|
||||||
|
1. Visita il sito
|
||||||
|
2. Tocca il menu (⋮) > "Aggiungi a schermata Home"
|
||||||
|
3. L'app si avvierà in modalità fullscreen standalone
|
||||||
|
|
||||||
|
**Su iOS/Safari:**
|
||||||
|
1. Visita il sito
|
||||||
|
2. Tocca il pulsante Condividi
|
||||||
|
3. Seleziona "Aggiungi a Home"
|
||||||
|
|
||||||
|
## Dettagli tecnici
|
||||||
|
|
||||||
|
### Architettura del componente
|
||||||
|
|
||||||
|
L'applicazione è strutturata come Single Page Application (SPA) con un unico componente principale `HomePage`. Questo design semplice è ideale per un'app focalizzata su un'unica funzionalità.
|
||||||
|
|
||||||
|
### Gestione dello stato
|
||||||
|
|
||||||
|
Lo stato dell'applicazione è gestito localmente nel componente tramite `data()`:
|
||||||
|
- `punt`: punteggio corrente delle due squadre
|
||||||
|
- `set`: numero di set vinti
|
||||||
|
- `servHome`: boolean che indica quale squadra ha il servizio
|
||||||
|
- `form`: array con le posizioni dei giocatori (1-6)
|
||||||
|
- `nomi`: nomi personalizzabili delle squadre
|
||||||
|
- `visuForm`: toggle tra visualizzazione punteggio/formazione
|
||||||
|
- `visuButt`: visibilità della barra dei pulsanti
|
||||||
|
|
||||||
|
### Gestione della rotazione
|
||||||
|
|
||||||
|
La rotazione dei giocatori segue le regole del pallavolo:
|
||||||
|
|
||||||
|
**Incremento punteggio** ([HomePage.vue:74-78](src/components/HomePage.vue:74-78)):
|
||||||
|
```javascript
|
||||||
|
incPunt(team) {
|
||||||
|
this.sp.punt[team]++;
|
||||||
|
this.sp.servHome = (team == "home");
|
||||||
|
this.sp.form[team].push(this.sp.form[team].shift());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Il servizio passa alla squadra che ha segnato
|
||||||
|
- La rotazione avviene con `push(shift())`: il primo elemento va in fondo
|
||||||
|
- Simula la rotazione oraria dei giocatori
|
||||||
|
|
||||||
|
**Decremento punteggio** ([HomePage.vue:79-85](src/components/HomePage.vue:79-85)):
|
||||||
|
```javascript
|
||||||
|
decPunt(team) {
|
||||||
|
if (this.sp.punt[team] > 0) {
|
||||||
|
this.sp.punt[team]--;
|
||||||
|
this.sp.form[team].unshift(this.sp.form[team].pop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- La rotazione inversa avviene con `unshift(pop())`: l'ultimo elemento va all'inizio
|
||||||
|
- Permette di annullare errori di punteggio
|
||||||
|
|
||||||
|
### Sintesi vocale
|
||||||
|
|
||||||
|
La funzione `speak()` ([HomePage.vue:86-112](src/components/HomePage.vue:86-112)) utilizza la Web Speech API:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
VitePWA({
|
const msg = new SpeechSynthesisUtterance();
|
||||||
registerType: 'autoUpdate',
|
msg.lang = 'it_IT';
|
||||||
manifest: {
|
msg.voice = voices.find(voice => voice.name === 'Google italiano');
|
||||||
name: "app_segnap",
|
|
||||||
short_name: "segnap",
|
|
||||||
description: "Segnapunti standalone.",
|
|
||||||
background_color: "#eee",
|
|
||||||
theme_color: '#ffffff',
|
|
||||||
display: "fullscreen",
|
|
||||||
orientation: "landscape",
|
|
||||||
icons: [
|
|
||||||
{ src: 'segnap-192x192.png', sizes: '192x192', type: 'image/png' },
|
|
||||||
{ src: 'segnap-512x512.png', sizes: '512x512', type: 'image/png' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Caratteristiche PWA
|
Logica di annuncio:
|
||||||
|
- "zero a zero" se il punteggio è 0-0
|
||||||
|
- "[numero] pari" se il punteggio è uguale
|
||||||
|
- "[servizio] a [ricezione]" annunciando prima chi ha il servizio
|
||||||
|
|
||||||
- **Display**: Fullscreen per massimizzare lo spazio visivo
|
### Prevenzione spegnimento schermo
|
||||||
- **Orientamento**: Landscape (orizzontale) ottimizzato per tablet
|
|
||||||
- **Auto-update**: Service Worker con aggiornamento automatico
|
|
||||||
- **Offline**: Funzionamento completo senza connessione internet
|
|
||||||
- **Installabile**: Aggiungibile alla home screen come app nativa
|
|
||||||
|
|
||||||
### Installazione PWA
|
NoSleep.js ([HomePage.vue:32-33](src/components/HomePage.vue:32-33)) viene attivato al mount su dispositivi mobili:
|
||||||
|
```javascript
|
||||||
**Android/Desktop (Chrome):**
|
var noSleep = new NoSleep();
|
||||||
- Menu → "Installa app" o icona (⊕) nella barra degli indirizzi
|
noSleep.enable();
|
||||||
|
|
||||||
**iOS (Safari):**
|
|
||||||
- Share (□↑) → "Aggiungi a Home"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Logica Regolamentare Pallavolo
|
|
||||||
|
|
||||||
### Vittoria Set
|
|
||||||
|
|
||||||
- **Set regolari (1-4)**: Primo a 25 punti con almeno 2 di vantaggio
|
|
||||||
- **Set decisivo**:
|
|
||||||
- Modalità 2/3: 3° set a 15 punti con almeno 2 di vantaggio
|
|
||||||
- Modalità 3/5: 5° set a 15 punti con almeno 2 di vantaggio
|
|
||||||
- **Blocco automatico**: Non consente assegnare punti oltre la vittoria
|
|
||||||
|
|
||||||
### Rotazione Formazione
|
|
||||||
|
|
||||||
La rotazione avviene **automaticamente** quando:
|
|
||||||
1. La squadra **conquista il servizio** (cambio palla)
|
|
||||||
2. Il punteggio è diverso da 0-0
|
|
||||||
|
|
||||||
**Limitazione cambio palla manuale:**
|
|
||||||
- Il cambio manuale del servizio (`Ctrl + ←`) è consentito **solo a 0-0**
|
|
||||||
- Questa limitazione previene errori nella rotazione delle formazioni
|
|
||||||
|
|
||||||
### Formazione in Campo
|
|
||||||
|
|
||||||
Visualizzazione a 6 posizioni standard:
|
|
||||||
|
|
||||||
```
|
|
||||||
Rete
|
|
||||||
┌─────┬─────┬─────┐
|
|
||||||
│ 4 │ 3 │ 2 │ ← Fila anteriore
|
|
||||||
├─────┼─────┼─────┤
|
|
||||||
│ 5 │ 6 │ 1 │ ← Fila posteriore
|
|
||||||
└─────┴─────┴─────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
La rotazione avviene in senso orario: 1→6→5→4→3→2→1
|
Questo impedisce allo schermo di spegnersi durante le partite, funzionalità essenziale per un segnapunti.
|
||||||
|
|
||||||
|
### Controlli da tastiera
|
||||||
|
|
||||||
|
Gli eventi tastiera sono gestiti tramite listener globale ([HomePage.vue:121-150](src/components/HomePage.vue:121-150)):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.addEventListener("keydown", this.funzioneTastiSpeciali);
|
||||||
|
```
|
||||||
|
|
||||||
|
I listener vengono temporaneamente disabilitati quando si aprono dialog per permettere l'inserimento di testo.
|
||||||
|
|
||||||
|
### Progressive Web App
|
||||||
|
|
||||||
|
La configurazione PWA in [vite.config.js](vite.config.js:10-33) definisce:
|
||||||
|
|
||||||
|
- **Manifest**: metadati dell'app (nome, icone, orientamento)
|
||||||
|
- **Service Worker**: strategia di cache per funzionamento offline
|
||||||
|
- **Display mode**: fullscreen per esperienza app-like
|
||||||
|
- **Orientamento forzato**: landscape per massimizzare spazio visibile
|
||||||
|
|
||||||
|
## Setup IDE raccomandato
|
||||||
|
|
||||||
|
- [VS Code](https://code.visualstudio.com/) - Editor consigliato
|
||||||
|
- [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) - Supporto Vue 3 (disabilita Vetur se installato)
|
||||||
|
- [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) - IntelliSense migliorato
|
||||||
|
|
||||||
|
### Estensioni VS Code utili
|
||||||
|
|
||||||
|
File [.vscode/extensions.json](.vscode/extensions.json) suggerisce:
|
||||||
|
- Vue Language Features (Volar)
|
||||||
|
- TypeScript Vue Plugin
|
||||||
|
|
||||||
|
## Risoluzione problemi comuni
|
||||||
|
|
||||||
|
### La sintesi vocale non funziona
|
||||||
|
|
||||||
|
- Verifica che il browser supporti la Web Speech API (Chrome/Edge consigliati)
|
||||||
|
- Su alcuni browser è necessaria l'interazione utente prima di utilizzare la sintesi vocale
|
||||||
|
- Controlla che la voce "Google italiano" sia disponibile nel sistema
|
||||||
|
|
||||||
|
### L'app non si installa come PWA
|
||||||
|
|
||||||
|
- Verifica che il sito sia servito tramite HTTPS (richiesto per Service Workers)
|
||||||
|
- Controlla che le icone in `public/` siano presenti e accessibili
|
||||||
|
- Ispeziona la console del browser per errori del Service Worker
|
||||||
|
|
||||||
|
### Lo schermo si spegne comunque
|
||||||
|
|
||||||
|
- NoSleep.js richiede un'interazione utente per attivarsi
|
||||||
|
- Su iOS, la prevenzione dello spegnimento ha limitazioni del sistema operativo
|
||||||
|
- Verifica che non ci siano impostazioni di risparmio energetico aggressive
|
||||||
|
|
||||||
|
### La rotazione non funziona correttamente
|
||||||
|
|
||||||
|
- Le formazioni si azzerano con il RESET
|
||||||
|
- Verifica che gli array `form` abbiano sempre 6 elementi
|
||||||
|
- La visualizzazione può essere invertita ma la logica di rotazione rimane corretta
|
||||||
|
|
||||||
|
## Licenza
|
||||||
|
|
||||||
|
Progetto privato. Tutti i diritti riservati.
|
||||||
|
|||||||
41
build-apk.sh
Executable file
41
build-apk.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo " Building APK for Segnapunti Android "
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Rileva architettura
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
echo "Architettura rilevata: $ARCH"
|
||||||
|
|
||||||
|
# Crea directory dist/android se non esiste
|
||||||
|
mkdir -p dist/android
|
||||||
|
|
||||||
|
echo "Building Docker image..."
|
||||||
|
docker build --platform linux/$(uname -m) -t segnapunti-android-builder .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Building APK inside Docker container..."
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(pwd)/dist:/app/dist" \
|
||||||
|
segnapunti-android-builder
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Fixing permissions on dist folder..."
|
||||||
|
sudo chown -R $USER:$USER dist
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
echo "Build completed successfully!"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
echo "APK location: $(pwd)/dist/android/segnapunti-debug.apk"
|
||||||
|
echo ""
|
||||||
|
echo "Per installare su dispositivo Android:"
|
||||||
|
echo " adb install dist/android/segnapunti-debug.apk"
|
||||||
|
echo ""
|
||||||
|
echo "Oppure trasferisci il file sul dispositivo"
|
||||||
|
echo "e installalo manualmente."
|
||||||
|
echo ""
|
||||||
21
capacitor.config.json
Normal file
21
capacitor.config.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.antoniana.segnapunti",
|
||||||
|
"appName": "Segnapunti",
|
||||||
|
"webDir": "dist",
|
||||||
|
"bundledWebRuntime": false,
|
||||||
|
"server": {
|
||||||
|
"androidScheme": "https"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"allowMixedContent": true,
|
||||||
|
"webContentsDebuggingEnabled": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"SplashScreen": {
|
||||||
|
"launchShowDuration": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preferences": {
|
||||||
|
"orientation": "landscape"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
generate-icons.sh
Executable file
42
generate-icons.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script per generare automaticamente le icone Android da segnap-512x512.png
|
||||||
|
|
||||||
|
SOURCE="./public/segnap-512x512.png"
|
||||||
|
BASE_DIR="./android/app/src/main/res"
|
||||||
|
|
||||||
|
# Verifica che ImageMagick sia installato
|
||||||
|
if ! command -v convert &> /dev/null; then
|
||||||
|
echo "Errore: ImageMagick non è installato."
|
||||||
|
echo "Installa con: sudo apt-get install imagemagick"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verifica che il file sorgente esista
|
||||||
|
if [ ! -f "$SOURCE" ]; then
|
||||||
|
echo "Errore: File sorgente $SOURCE non trovato"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verifica che la directory android esista
|
||||||
|
if [ ! -d "./android" ]; then
|
||||||
|
echo "Errore: Directory android/ non trovata."
|
||||||
|
echo "Esegui prima: npx cap add android"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Generazione icone Android da $SOURCE..."
|
||||||
|
|
||||||
|
# Crea le directory se non esistono e genera le icone
|
||||||
|
for size in "mdpi:48" "hdpi:72" "xhdpi:96" "xxhdpi:144" "xxxhdpi:192"; do
|
||||||
|
density="${size%:*}"
|
||||||
|
pixels="${size#*:}"
|
||||||
|
dir="$BASE_DIR/mipmap-$density"
|
||||||
|
|
||||||
|
mkdir -p "$dir"
|
||||||
|
convert "$SOURCE" -resize ${pixels}x${pixels} "$dir/ic_launcher.png"
|
||||||
|
echo " ✓ Creata icona $density (${pixels}x${pixels})"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Icone generate con successo!"
|
||||||
|
echo ""
|
||||||
|
echo "Prossimo passo: npx cap sync"
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<title>Segnapunti - Anto</title>
|
<title>Segnapunti - Anto</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
6205
package-lock.json
generated
6205
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -6,16 +6,25 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"cap:sync": "npm run build && cap sync",
|
||||||
|
"cap:open": "cap open android",
|
||||||
|
"android:build:debug": "cd android && ./gradlew assembleDebug",
|
||||||
|
"docker:build": "./build-apk.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor-community/text-to-speech": "^6.0.0",
|
||||||
|
"@capacitor/android": "^6.2.0",
|
||||||
|
"@capacitor/app": "^6.0.1",
|
||||||
|
"@capacitor/core": "^6.2.0",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.4.38",
|
||||||
"wave-ui": "^3.3.0"
|
"wave-ui": "^3.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.1.0",
|
"@capacitor/cli": "^6.2.0",
|
||||||
"vite": "^4.3.9",
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
"vite-plugin-pwa": "^0.16.0"
|
"vite": "^5.4.10",
|
||||||
|
"vite-plugin-pwa": "^0.20.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
setup-android-icons.sh
Normal file
40
setup-android-icons.sh
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Configurazione icone Android..."
|
||||||
|
|
||||||
|
# Rimuove icone default di Capacitor
|
||||||
|
rm -rf android/app/src/main/res/mipmap-*
|
||||||
|
|
||||||
|
# Crea directory per tutte le densità
|
||||||
|
for density in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
|
||||||
|
mkdir -p android/app/src/main/res/mipmap-$density
|
||||||
|
done
|
||||||
|
|
||||||
|
# Mappa densità -> dimensioni
|
||||||
|
declare -A sizes=(
|
||||||
|
["mdpi"]="48"
|
||||||
|
["hdpi"]="72"
|
||||||
|
["xhdpi"]="96"
|
||||||
|
["xxhdpi"]="144"
|
||||||
|
["xxxhdpi"]="192"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Genera icone per ogni densità
|
||||||
|
for density in "${!sizes[@]}"; do
|
||||||
|
size="${sizes[$density]}"
|
||||||
|
echo " Generando icone ${size}x${size} per $density..."
|
||||||
|
|
||||||
|
convert public/segnap-512x512.png -resize ${size}x${size} \
|
||||||
|
android/app/src/main/res/mipmap-$density/ic_launcher.png
|
||||||
|
|
||||||
|
convert public/segnap-512x512.png -resize ${size}x${size} \
|
||||||
|
android/app/src/main/res/mipmap-$density/ic_launcher_round.png
|
||||||
|
done
|
||||||
|
|
||||||
|
# Icona per il file APK (visibile da PC)
|
||||||
|
mkdir -p android/app/src/main/res/drawable
|
||||||
|
convert public/segnap-512x512.png -resize 512x512 \
|
||||||
|
android/app/src/main/res/drawable/icon.png
|
||||||
|
|
||||||
|
echo "Icone configurate con successo!"
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import NoSleep from "nosleep.js";
|
import NoSleep from "nosleep.js";
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { App } from '@capacitor/app';
|
||||||
|
import { TextToSpeech } from '@capacitor-community/text-to-speech';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "HomePage",
|
name: "HomePage",
|
||||||
components: {},
|
components: {},
|
||||||
@@ -26,9 +30,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
// Carica le voci (necessario su alcuni browser)
|
||||||
this.voices = window.speechSynthesis.getVoices();
|
this.voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
// Ascolta l'evento voiceschanged per Android/Chrome
|
||||||
|
if (speechSynthesis.onvoiceschanged !== undefined) {
|
||||||
|
speechSynthesis.onvoiceschanged = () => {
|
||||||
|
this.voices = window.speechSynthesis.getVoices();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isMobile()) {
|
if (this.isMobile()) {
|
||||||
this.speak();
|
|
||||||
var noSleep = new NoSleep();
|
var noSleep = new NoSleep();
|
||||||
noSleep.enable();
|
noSleep.enable();
|
||||||
document.documentElement.requestFullscreen();
|
document.documentElement.requestFullscreen();
|
||||||
@@ -83,32 +95,64 @@ export default {
|
|||||||
this.sp.form[team].unshift(this.sp.form[team].pop());
|
this.sp.form[team].unshift(this.sp.form[team].pop());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
speak() {
|
async speak() {
|
||||||
const msg = new SpeechSynthesisUtterance();
|
// Prepara il testo da pronunciare
|
||||||
|
let text = "";
|
||||||
if (this.sp.punt.home + this.sp.punt.guest == 0) {
|
if (this.sp.punt.home + this.sp.punt.guest == 0) {
|
||||||
msg.text = "zero a zero";
|
text = "zero a zero";
|
||||||
} else if (this.sp.punt.home == this.sp.punt.guest) {
|
} else if (this.sp.punt.home == this.sp.punt.guest) {
|
||||||
msg.text = this.sp.punt.home + " pari";
|
text = this.sp.punt.home + " pari";
|
||||||
} else {
|
} else {
|
||||||
if (this.sp.servHome) {
|
if (this.sp.servHome) {
|
||||||
msg.text = this.sp.punt.home + " a " + this.sp.punt.guest;
|
text = this.sp.punt.home + " a " + this.sp.punt.guest;
|
||||||
} else {
|
} else {
|
||||||
msg.text = this.sp.punt.guest + " a " + this.sp.punt.home;
|
text = this.sp.punt.guest + " a " + this.sp.punt.home;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// msg.volume = 1.0; // speech volume (default: 1.0)
|
|
||||||
// msg.pitch = 1.0; // speech pitch (default: 1.0)
|
console.log('Speak called, text:', text);
|
||||||
// msg.rate = 1.0; // speech rate (default: 1.0)
|
console.log('Is native platform:', Capacitor.isNativePlatform());
|
||||||
msg.lang = 'it_IT'; // speech language (default: 'en-US')
|
|
||||||
const voices = window.speechSynthesis.getVoices();
|
// Usa plugin nativo su mobile, Web API su desktop
|
||||||
msg.voice = voices.find(voice => voice.name === 'Google italiano'); // voice URI (default: platform-dependent)
|
if (Capacitor.isNativePlatform()) {
|
||||||
// msg.onboundary = function (event) {
|
try {
|
||||||
// console.log('Speech reached a boundary:', event.name);
|
console.log('Using native TTS');
|
||||||
// };
|
await TextToSpeech.speak({
|
||||||
// msg.onpause = function (event) {
|
text: text,
|
||||||
// console.log('Speech paused:', event.utterance.text.substring(event.charIndex));
|
lang: 'it-IT',
|
||||||
// };
|
rate: 0.9,
|
||||||
window.speechSynthesis.speak(msg);
|
pitch: 1.0,
|
||||||
|
volume: 1.0,
|
||||||
|
category: 'ambient'
|
||||||
|
});
|
||||||
|
console.log('TTS completed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TTS error:', error);
|
||||||
|
this.$waveui.notify('Errore TTS: ' + error.message, 'error', 3000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback Web API per desktop/browser
|
||||||
|
console.log('Using web API speechSynthesis');
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
|
||||||
|
const msg = new SpeechSynthesisUtterance();
|
||||||
|
msg.text = text;
|
||||||
|
msg.volume = 1.0;
|
||||||
|
msg.rate = 0.9;
|
||||||
|
msg.pitch = 1.0;
|
||||||
|
msg.lang = 'it-IT';
|
||||||
|
|
||||||
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
const italianVoice = voices.find(voice =>
|
||||||
|
voice.lang.startsWith('it') || voice.name.includes('Italian') || voice.name.includes('italiano')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (italianVoice) {
|
||||||
|
msg.voice = italianVoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.speechSynthesis.speak(msg);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
apriDialogConfig() {
|
apriDialogConfig() {
|
||||||
this.disabilitaTastiSpeciali();
|
this.disabilitaTastiSpeciali();
|
||||||
|
|||||||
@@ -1,126 +1,28 @@
|
|||||||
<section class="homepage">
|
<section class="homepage">
|
||||||
<w-dialog v-model="diaNomi.show" :width="600" @close="chiudiDialogConfig()">
|
<w-dialog v-model="diaNomi.show" :width="500" @close="abilitaTastiSpeciali()">
|
||||||
<w-input v-model="sp.nomi.home" type="text" class="pa3" tabindex="1">Nome Home</w-input>
|
<w-input v-model="sp.nomi.home" type="text" class="pa3">Home</w-input>
|
||||||
<w-input v-model="sp.nomi.guest" type="text" class="pa3" tabindex="2">Nome Guest</w-input>
|
<w-input v-model="sp.nomi.guest" type="text" class="pa3">Guest</w-input>
|
||||||
|
<w-button @click="order = !order">Inverti ordine</w-button>
|
||||||
<w-flex justify-center align-center class="pa3">
|
<w-button bg-color="success" @click="diaNomi.show = false">
|
||||||
<span class="mr3">Modalità partita:</span>
|
|
||||||
<w-button
|
|
||||||
@click="modalitaPartita = '2/3'"
|
|
||||||
:bg-color="modalitaPartita === '2/3' ? 'success' : 'grey-light4'"
|
|
||||||
:dark="modalitaPartita === '2/3'"
|
|
||||||
class="ma1"
|
|
||||||
tabindex="-1">
|
|
||||||
2/3
|
|
||||||
</w-button>
|
|
||||||
<w-button
|
|
||||||
@click="modalitaPartita = '3/5'"
|
|
||||||
:bg-color="modalitaPartita === '3/5' ? 'success' : 'grey-light4'"
|
|
||||||
:dark="modalitaPartita === '3/5'"
|
|
||||||
class="ma1"
|
|
||||||
tabindex="-1">
|
|
||||||
3/5
|
|
||||||
</w-button>
|
|
||||||
</w-flex>
|
|
||||||
|
|
||||||
<w-flex justify-space-around class="pa3">
|
|
||||||
<div class="campo-config">
|
|
||||||
<div class="text-bold mb3 text-center">Formazione Home</div>
|
|
||||||
<div class="campo-pallavolo">
|
|
||||||
<!-- Fila anteriore - index [3, 2, 1] - VICINO ALLA RETE (prima fila visualizzata) -->
|
|
||||||
<w-flex justify-center class="fila-anteriore">
|
|
||||||
<w-input v-model="sp.form.home[3]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="6"></w-input>
|
|
||||||
<w-input v-model="sp.form.home[2]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="5"></w-input>
|
|
||||||
<w-input v-model="sp.form.home[1]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="4"></w-input>
|
|
||||||
</w-flex>
|
|
||||||
<!-- Linea dei 3 metri -->
|
|
||||||
<div class="linea-tre-metri"></div>
|
|
||||||
<!-- Fila posteriore - index [4, 5, 0] - ZONA DIFESA (seconda fila visualizzata) -->
|
|
||||||
<w-flex justify-center class="fila-posteriore">
|
|
||||||
<w-input v-model="sp.form.home[4]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="7"></w-input>
|
|
||||||
<w-input v-model="sp.form.home[5]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="8"></w-input>
|
|
||||||
<w-input v-model="sp.form.home[0]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="3"></w-input>
|
|
||||||
</w-flex>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="campo-config">
|
|
||||||
<div class="text-bold mb3 text-center">Formazione Guest</div>
|
|
||||||
<div class="campo-pallavolo">
|
|
||||||
<!-- Fila anteriore - index [3, 2, 1] - VICINO ALLA RETE (prima fila visualizzata) -->
|
|
||||||
<w-flex justify-center class="fila-anteriore">
|
|
||||||
<w-input v-model="sp.form.guest[3]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="12"></w-input>
|
|
||||||
<w-input v-model="sp.form.guest[2]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="11"></w-input>
|
|
||||||
<w-input v-model="sp.form.guest[1]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="10"></w-input>
|
|
||||||
</w-flex>
|
|
||||||
<!-- Linea dei 3 metri -->
|
|
||||||
<div class="linea-tre-metri"></div>
|
|
||||||
<!-- Fila posteriore - index [4, 5, 0] - ZONA DIFESA (seconda fila visualizzata) -->
|
|
||||||
<w-flex justify-center class="fila-posteriore">
|
|
||||||
<w-input v-model="sp.form.guest[4]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="13"></w-input>
|
|
||||||
<w-input v-model="sp.form.guest[5]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="14"></w-input>
|
|
||||||
<w-input v-model="sp.form.guest[0]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="9"></w-input>
|
|
||||||
</w-flex>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</w-flex>
|
|
||||||
|
|
||||||
<w-button @click="order = !order" class="ma2" tabindex="-1">Inverti ordine</w-button>
|
|
||||||
<w-button bg-color="success" @click="diaNomi.show = false" class="ma2" tabindex="-1">
|
|
||||||
Ok
|
Ok
|
||||||
</w-button>
|
</w-button>
|
||||||
</w-dialog>
|
</w-dialog>
|
||||||
<w-dialog v-model="diaCambiTeam.show" :width="420" @close="abilitaTastiSpeciali()">
|
|
||||||
<div class="text-bold text-center mb2">Scegli squadra</div>
|
|
||||||
<w-flex justify-center class="pa3">
|
|
||||||
<w-button class="ma2" @click="selezionaTeamCambi('home')">{{ sp.nomi.home }}</w-button>
|
|
||||||
<w-button class="ma2" @click="selezionaTeamCambi('guest')">{{ sp.nomi.guest }}</w-button>
|
|
||||||
</w-flex>
|
|
||||||
</w-dialog>
|
|
||||||
<w-dialog v-model="diaCambi.show" :width="360" @close="chiudiDialogCambi">
|
|
||||||
<div class="cambi-dialog">
|
|
||||||
<div class="cambi-title">{{ sp.nomi[diaCambi.team] }}: CAMBIO</div>
|
|
||||||
<div class="cambi-rows">
|
|
||||||
<div class="cambi-row">
|
|
||||||
<w-input v-model="diaCambi[diaCambi.team].cambi[0].in" type="text" class="cambi-input cambi-in"></w-input>
|
|
||||||
<span class="cambi-arrow">→</span>
|
|
||||||
<w-input v-model="diaCambi[diaCambi.team].cambi[0].out" type="text" class="cambi-input cambi-out"></w-input>
|
|
||||||
</div>
|
|
||||||
<div class="cambi-row">
|
|
||||||
<w-input v-model="diaCambi[diaCambi.team].cambi[1].in" type="text" class="cambi-input cambi-in"></w-input>
|
|
||||||
<span class="cambi-arrow">→</span>
|
|
||||||
<w-input v-model="diaCambi[diaCambi.team].cambi[1].out" type="text" class="cambi-input cambi-out"></w-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<w-flex justify-end class="pa3">
|
|
||||||
<w-button bg-color="success" :disabled="!cambiConfermabili" @click="confermaCambi">
|
|
||||||
CONFERMA
|
|
||||||
</w-button>
|
|
||||||
</w-flex>
|
|
||||||
</w-dialog>
|
|
||||||
<div class="campo">
|
<div class="campo">
|
||||||
|
|
||||||
<span v-if="order">
|
<span v-if="order">
|
||||||
<!-- home guest -->
|
<!-- home guest -->
|
||||||
<div class="hea home">
|
<div class="hea home">
|
||||||
<span @click="decPunt('home')" :style="{ 'float': 'left' }">
|
<span @click="decPunt('home')" :style="{ 'float': 'left' }">
|
||||||
{{ sp.nomi.home }}
|
{{ sp.nomi.home }} <img v-if="sp.servHome" src="/serv.png" width="25" />
|
||||||
<span class="serv-slot">
|
<span v-if="visuForm">{{ sp.punt.home }}</span>
|
||||||
<img v-show="sp.servHome" src="/serv.png" width="25" />
|
|
||||||
</span>
|
|
||||||
<span v-if="visuForm" class="score-inline">{{ sp.punt.home }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span>
|
<span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hea guest">
|
<div class="hea guest">
|
||||||
<span @click="decPunt('guest')" :style="{ 'float': 'right' }">
|
<span @click="decPunt('guest')" :style="{ 'float': 'right' }">
|
||||||
<span v-if="visuForm" class="score-inline">{{ sp.punt.guest }}</span>
|
<img v-if="!sp.servHome" src="/serv.png" width="25" /> {{ sp.nomi.guest }}
|
||||||
<span class="serv-slot">
|
<span v-if="visuForm">{{ sp.punt.guest }}</span>
|
||||||
<img v-show="!sp.servHome" src="/serv.png" width="25" />
|
|
||||||
</span>
|
|
||||||
{{ sp.nomi.guest }}
|
|
||||||
</span>
|
</span>
|
||||||
<span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span>
|
<span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,10 +40,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
<w-flex class="punteggio-container">
|
<div class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</div>
|
||||||
<w-flex justify-center align-center class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</w-flex>
|
<div class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</div>
|
||||||
<w-flex justify-center align-center class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</w-flex>
|
|
||||||
</w-flex>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
@@ -150,22 +50,16 @@
|
|||||||
|
|
||||||
<div class="hea guest">
|
<div class="hea guest">
|
||||||
<span @click="decPunt('guest')" :style="{ 'float': 'left' }">
|
<span @click="decPunt('guest')" :style="{ 'float': 'left' }">
|
||||||
{{ sp.nomi.guest }}
|
{{ sp.nomi.guest }} <img v-if="!sp.servHome" src="/serv.png" width="25" />
|
||||||
<span class="serv-slot">
|
<span v-if="visuForm">{{ sp.punt.guest }}</span>
|
||||||
<img v-show="!sp.servHome" src="/serv.png" width="25" />
|
|
||||||
</span>
|
|
||||||
<span v-if="visuForm" class="score-inline">{{ sp.punt.guest }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span @click="incSet('guest')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.guest }}</span>
|
<span @click="incSet('guest')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.guest }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hea home">
|
<div class="hea home">
|
||||||
<span @click="decPunt('home')" :style="{ 'float': 'right' }">
|
<span @click="decPunt('home')" :style="{ 'float': 'right' }">
|
||||||
<span v-if="visuForm" class="score-inline">{{ sp.punt.home }}</span>
|
<img v-if="sp.servHome" src="/serv.png" width="25" /> {{ sp.nomi.home }}
|
||||||
<span class="serv-slot">
|
<span v-if="visuForm">{{ sp.punt.home }}</span>
|
||||||
<img v-show="sp.servHome" src="/serv.png" width="25" />
|
|
||||||
</span>
|
|
||||||
{{ sp.nomi.home }}
|
|
||||||
</span>
|
</span>
|
||||||
<span @click="incSet('home')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.home }}</span>
|
<span @click="incSet('home')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.home }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,10 +77,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
<w-flex class="punteggio-container">
|
<div class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</div>
|
||||||
<w-flex justify-center align-center class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</w-flex>
|
<div class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</div>
|
||||||
<w-flex justify-center align-center class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</w-flex>
|
|
||||||
</w-flex>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
@@ -214,7 +106,7 @@
|
|||||||
<w-button @click="apriDialogConfig()">
|
<w-button @click="apriDialogConfig()">
|
||||||
<img src="/gear.png" width="25" />
|
<img src="/gear.png" width="25" />
|
||||||
</w-button>
|
</w-button>
|
||||||
<w-button @click="cambiaPalla" :disabled="!isPunteggioZeroZero">
|
<w-button @click="sp.servHome = !sp.servHome">
|
||||||
<img src="/serv.png" width="25" />
|
<img src="/serv.png" width="25" />
|
||||||
</w-button>
|
</w-button>
|
||||||
<w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta">
|
<w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta">
|
||||||
@@ -224,9 +116,6 @@
|
|||||||
<span v-if="visuForm">PUNTEGGIO</span>
|
<span v-if="visuForm">PUNTEGGIO</span>
|
||||||
<span v-if="!visuForm">FORMAZIONI</span>
|
<span v-if="!visuForm">FORMAZIONI</span>
|
||||||
</w-button>
|
</w-button>
|
||||||
<w-button @click="apriDialogCambi">
|
|
||||||
CAMBI
|
|
||||||
</w-button>
|
|
||||||
<w-button @click="visuStriscia = !visuStriscia">
|
<w-button @click="visuStriscia = !visuStriscia">
|
||||||
STRISCIA
|
STRISCIA
|
||||||
</w-button>
|
</w-button>
|
||||||
@@ -237,4 +126,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -11,19 +11,9 @@ export default {
|
|||||||
home: "",
|
home: "",
|
||||||
guest: "",
|
guest: "",
|
||||||
},
|
},
|
||||||
diaCambi: {
|
|
||||||
show: false,
|
|
||||||
team: "home",
|
|
||||||
guest: { cambi: [{ in: "", out: "" }, { in: "", out: "" }] },
|
|
||||||
home: { cambi: [{ in: "", out: "" }, { in: "", out: "" }] },
|
|
||||||
},
|
|
||||||
diaCambiTeam: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
visuForm: false,
|
visuForm: false,
|
||||||
visuButt: true,
|
visuButt: true,
|
||||||
visuStriscia: true,
|
visuStriscia: true,
|
||||||
modalitaPartita: "3/5", // "2/3" o "3/5"
|
|
||||||
sp: {
|
sp: {
|
||||||
striscia: { home: [0], guest: [0] },
|
striscia: { home: [0], guest: [0] },
|
||||||
servHome: true,
|
servHome: true,
|
||||||
@@ -34,7 +24,6 @@ export default {
|
|||||||
home: ["1", "2", "3", "4", "5", "6"],
|
home: ["1", "2", "3", "4", "5", "6"],
|
||||||
guest: ["1", "2", "3", "4", "5", "6"],
|
guest: ["1", "2", "3", "4", "5", "6"],
|
||||||
},
|
},
|
||||||
storicoServizio: [], // Stack per tracciare lo stato del servizio prima di ogni punto
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -48,33 +37,6 @@ export default {
|
|||||||
}
|
}
|
||||||
this.abilitaTastiSpeciali();
|
this.abilitaTastiSpeciali();
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
isPunteggioZeroZero() {
|
|
||||||
return this.sp.punt.home === 0 && this.sp.punt.guest === 0;
|
|
||||||
},
|
|
||||||
cambiConfermabili() {
|
|
||||||
const team = this.diaCambi.team;
|
|
||||||
const cambi = this.diaCambi[team].cambi || [];
|
|
||||||
let hasComplete = false;
|
|
||||||
let allValid = true;
|
|
||||||
|
|
||||||
cambi.forEach((cambio) => {
|
|
||||||
const teamIn = (cambio.in || "").trim();
|
|
||||||
const teamOut = (cambio.out || "").trim();
|
|
||||||
|
|
||||||
if (!teamIn && !teamOut) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!teamIn || !teamOut) {
|
|
||||||
allValid = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hasComplete = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return allValid && hasComplete;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
closeApp() {
|
closeApp() {
|
||||||
var win = window.open("", "_self");
|
var win = window.open("", "_self");
|
||||||
@@ -104,14 +66,6 @@ export default {
|
|||||||
guest: ["1", "2", "3", "4", "5", "6"],
|
guest: ["1", "2", "3", "4", "5", "6"],
|
||||||
}
|
}
|
||||||
this.sp.striscia = { home: [0], guest: [0] }
|
this.sp.striscia = { home: [0], guest: [0] }
|
||||||
this.sp.storicoServizio = []
|
|
||||||
},
|
|
||||||
cambiaPalla() {
|
|
||||||
if (!this.isPunteggioZeroZero) {
|
|
||||||
this.$waveui.notify("Cambio palla consentito solo a inizio set (0-0)", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.sp.servHome = !this.sp.servHome;
|
|
||||||
},
|
},
|
||||||
incSet(team) {
|
incSet(team) {
|
||||||
if (this.sp.set[team] == 2) {
|
if (this.sp.set[team] == 2) {
|
||||||
@@ -121,17 +75,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
incPunt(team) {
|
incPunt(team) {
|
||||||
// Se il set è già terminato, evita ulteriori incrementi
|
|
||||||
if (this.checkVittoria()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Salva lo stato del servizio PRIMA di modificarlo
|
|
||||||
this.sp.storicoServizio.push({
|
|
||||||
servHome: this.sp.servHome,
|
|
||||||
cambioPalla: (team == "home" && !this.sp.servHome) || (team == "guest" && this.sp.servHome)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sp.punt[team]++;
|
this.sp.punt[team]++;
|
||||||
if (team == 'home') {
|
if (team == 'home') {
|
||||||
this.sp.striscia.home.push(this.sp.punt.home)
|
this.sp.striscia.home.push(this.sp.punt.home)
|
||||||
@@ -139,69 +82,21 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.sp.striscia.guest.push(this.sp.punt.guest)
|
this.sp.striscia.guest.push(this.sp.punt.guest)
|
||||||
this.sp.striscia.home.push(' ')
|
this.sp.striscia.home.push(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ruota la formazione solo se c'è cambio palla (conquista del servizio)
|
|
||||||
const cambioPalla = (team == "home" && !this.sp.servHome) || (team == "guest" && this.sp.servHome);
|
|
||||||
|
|
||||||
if (cambioPalla) {
|
|
||||||
this.sp.form[team].push(this.sp.form[team].shift());
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sp.servHome = (team == "home");
|
this.sp.servHome = (team == "home");
|
||||||
},
|
this.sp.form[team].push(this.sp.form[team].shift());
|
||||||
checkVittoria() {
|
|
||||||
const puntHome = this.sp.punt.home;
|
|
||||||
const puntGuest = this.sp.punt.guest;
|
|
||||||
const setHome = this.sp.set.home;
|
|
||||||
const setGuest = this.sp.set.guest;
|
|
||||||
const totSet = setHome + setGuest;
|
|
||||||
|
|
||||||
// Determina se siamo nel set decisivo in base alla modalità partita
|
|
||||||
let isSetDecisivo = false;
|
|
||||||
if (this.modalitaPartita === "2/3") {
|
|
||||||
// Tie-break al 3° set (quando totSet >= 2)
|
|
||||||
isSetDecisivo = totSet >= 2;
|
|
||||||
} else {
|
|
||||||
// Tie-break al 5° set (quando totSet >= 4)
|
|
||||||
isSetDecisivo = totSet >= 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
const punteggioVittoria = isSetDecisivo ? 15 : 25;
|
|
||||||
|
|
||||||
// Vittoria con punteggio >= 25 (o 15 per set decisivo) e almeno 2 punti di vantaggio
|
|
||||||
if (puntHome >= punteggioVittoria && puntHome - puntGuest >= 2) {
|
|
||||||
return true; // Home ha vinto
|
|
||||||
}
|
|
||||||
if (puntGuest >= punteggioVittoria && puntGuest - puntHome >= 2) {
|
|
||||||
return true; // Guest ha vinto
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
decPunt() {
|
decPunt() {
|
||||||
if (this.sp.striscia.home.length > 1 && this.sp.storicoServizio.length > 0) {
|
if (this.sp.striscia.home.length > 1) {
|
||||||
var tmpHome = this.sp.striscia.home.pop()
|
var tmpHome = this.sp.striscia.home.pop()
|
||||||
var tmpGuest = this.sp.striscia.guest.pop()
|
var tmpGuest = this.sp.striscia.guest.pop()
|
||||||
var statoServizio = this.sp.storicoServizio.pop() // Recupera lo stato completo del servizio
|
|
||||||
|
|
||||||
if (tmpHome == ' ') {
|
if (tmpHome == ' ') {
|
||||||
this.sp.punt.guest--
|
this.sp.punt.guest--
|
||||||
// Ruota indietro solo se c'era stato un cambio palla
|
this.sp.form.guest.unshift(this.sp.form.guest.pop());
|
||||||
if (statoServizio.cambioPalla) {
|
|
||||||
this.sp.form.guest.unshift(this.sp.form.guest.pop());
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.sp.punt.home--
|
this.sp.punt.home--
|
||||||
// Ruota indietro solo se c'era stato un cambio palla
|
this.sp.form.home.unshift(this.sp.form.home.pop());
|
||||||
if (statoServizio.cambioPalla) {
|
|
||||||
this.sp.form.home.unshift(this.sp.form.home.pop());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ripristina il servizio allo stato precedente
|
|
||||||
this.sp.servHome = statoServizio.servHome;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// decPunt(team) {
|
// decPunt(team) {
|
||||||
@@ -244,152 +139,6 @@ export default {
|
|||||||
apriDialogConfig() {
|
apriDialogConfig() {
|
||||||
this.disabilitaTastiSpeciali();
|
this.disabilitaTastiSpeciali();
|
||||||
this.diaNomi.show = true;
|
this.diaNomi.show = true;
|
||||||
|
|
||||||
// Aggiungi gestore Tab per il dialog
|
|
||||||
this.dialogConfigTabHandler = (e) => {
|
|
||||||
if (e.key === 'Tab' && this.diaNomi.show) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const dialog = document.querySelector('.w-dialog');
|
|
||||||
if (!dialog) return;
|
|
||||||
|
|
||||||
const allInputs = Array.from(dialog.querySelectorAll('input[type="text"]'))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const tabA = parseInt(a.closest('[tabindex]')?.getAttribute('tabindex') || '0');
|
|
||||||
const tabB = parseInt(b.closest('[tabindex]')?.getAttribute('tabindex') || '0');
|
|
||||||
return tabA - tabB;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (allInputs.length === 0) return;
|
|
||||||
|
|
||||||
// Verifica se il focus è già dentro il dialog
|
|
||||||
const focusInDialog = dialog.contains(document.activeElement);
|
|
||||||
|
|
||||||
// Se non è nel dialog o non è in un input, vai al primo
|
|
||||||
if (!focusInDialog || !allInputs.includes(document.activeElement)) {
|
|
||||||
allInputs[0].focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigazione normale tra i campi
|
|
||||||
const currentIndex = allInputs.indexOf(document.activeElement);
|
|
||||||
let nextIndex;
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
nextIndex = currentIndex <= 0 ? allInputs.length - 1 : currentIndex - 1;
|
|
||||||
} else {
|
|
||||||
nextIndex = currentIndex >= allInputs.length - 1 ? 0 : currentIndex + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allInputs[nextIndex]) {
|
|
||||||
allInputs[nextIndex].focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', this.dialogConfigTabHandler, true);
|
|
||||||
|
|
||||||
// Focus immediato + retry con timeout
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const focusFirst = () => {
|
|
||||||
const dialog = document.querySelector('.w-dialog');
|
|
||||||
if (dialog) {
|
|
||||||
const firstInput = dialog.querySelector('input[type="text"]');
|
|
||||||
if (firstInput) {
|
|
||||||
firstInput.focus();
|
|
||||||
firstInput.select();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prova immediatamente
|
|
||||||
if (!focusFirst()) {
|
|
||||||
// Se fallisce, riprova dopo un breve delay
|
|
||||||
setTimeout(focusFirst, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
chiudiDialogConfig() {
|
|
||||||
if (this.dialogConfigTabHandler) {
|
|
||||||
window.removeEventListener('keydown', this.dialogConfigTabHandler, true);
|
|
||||||
this.dialogConfigTabHandler = null;
|
|
||||||
}
|
|
||||||
this.abilitaTastiSpeciali();
|
|
||||||
},
|
|
||||||
resettaCambi(team) {
|
|
||||||
const teams = team ? [team] : ["home", "guest"];
|
|
||||||
teams.forEach((t) => {
|
|
||||||
this.diaCambi[t].cambi.forEach((cambio) => {
|
|
||||||
cambio.in = "";
|
|
||||||
cambio.out = "";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
apriDialogCambi() {
|
|
||||||
this.disabilitaTastiSpeciali();
|
|
||||||
this.diaCambiTeam.show = true;
|
|
||||||
},
|
|
||||||
apriDialogCambiTeam(team) {
|
|
||||||
this.disabilitaTastiSpeciali();
|
|
||||||
this.diaCambi.team = team;
|
|
||||||
this.resettaCambi(team);
|
|
||||||
this.diaCambi.show = true;
|
|
||||||
},
|
|
||||||
selezionaTeamCambi(team) {
|
|
||||||
this.diaCambiTeam.show = false;
|
|
||||||
this.apriDialogCambiTeam(team);
|
|
||||||
},
|
|
||||||
chiudiDialogCambi() {
|
|
||||||
this.diaCambi.show = false;
|
|
||||||
this.resettaCambi(this.diaCambi.team);
|
|
||||||
this.abilitaTastiSpeciali();
|
|
||||||
},
|
|
||||||
confermaCambi() {
|
|
||||||
if (!this.cambiConfermabili) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = this.diaCambi.team;
|
|
||||||
const cambi = (this.diaCambi[team].cambi || [])
|
|
||||||
.map((cambio) => ({
|
|
||||||
team,
|
|
||||||
in: (cambio.in || "").trim(),
|
|
||||||
out: (cambio.out || "").trim(),
|
|
||||||
}))
|
|
||||||
.filter((cambio) => cambio.in || cambio.out);
|
|
||||||
|
|
||||||
const form = this.sp.form[team].map((val) => String(val).trim());
|
|
||||||
const formAggiornata = [...form];
|
|
||||||
|
|
||||||
for (const cambio of cambi) {
|
|
||||||
if (!/^\d+$/.test(cambio.in) || !/^\d+$/.test(cambio.out)) {
|
|
||||||
this.$waveui.notify("Inserisci solo numeri nei campi", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cambio.in === cambio.out) {
|
|
||||||
this.$waveui.notify(`Numero IN e OUT uguali per ${cambio.team}`, "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formAggiornata.includes(cambio.in)) {
|
|
||||||
this.$waveui.notify(`Numero ${cambio.in} già presente in formazione ${cambio.team}`, "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!formAggiornata.includes(cambio.out)) {
|
|
||||||
this.$waveui.notify(`Numero ${cambio.out} non presente in formazione ${cambio.team}`, "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const idx = formAggiornata.findIndex((val) => String(val).trim() === cambio.out);
|
|
||||||
if (idx !== -1) {
|
|
||||||
formAggiornata.splice(idx, 1, cambio.in);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sp.form[team] = formAggiornata;
|
|
||||||
|
|
||||||
this.chiudiDialogCambi();
|
|
||||||
},
|
},
|
||||||
disabilitaTastiSpeciali() {
|
disabilitaTastiSpeciali() {
|
||||||
window.removeEventListener("keydown", this.funzioneTastiSpeciali);
|
window.removeEventListener("keydown", this.funzioneTastiSpeciali);
|
||||||
@@ -398,82 +147,32 @@ export default {
|
|||||||
window.addEventListener("keydown", this.funzioneTastiSpeciali);
|
window.addEventListener("keydown", this.funzioneTastiSpeciali);
|
||||||
},
|
},
|
||||||
funzioneTastiSpeciali(e) {
|
funzioneTastiSpeciali(e) {
|
||||||
if (this.diaNomi.show || this.diaCambi.show || this.diaCambiTeam.show) {
|
e.preventDefault();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = e.target;
|
|
||||||
const path = typeof e.composedPath === "function" ? e.composedPath() : [];
|
|
||||||
const elements = [target, ...path].filter(Boolean);
|
|
||||||
const isTypingField = elements.some((el) => {
|
|
||||||
if (!el || !el.tagName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const tag = String(el.tagName).toLowerCase();
|
|
||||||
if (tag === "input" || tag === "textarea") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (el.isContentEditable) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (el.classList && (el.classList.contains("w-input") || el.classList.contains("w-textarea"))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const contentEditable = el.getAttribute && el.getAttribute("contenteditable");
|
|
||||||
return contentEditable === "true";
|
|
||||||
});
|
|
||||||
if (isTypingField) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let handled = false;
|
|
||||||
if (e.ctrlKey && e.key == "m") {
|
if (e.ctrlKey && e.key == "m") {
|
||||||
this.diaNomi.show = true
|
this.diaNomi.show = true
|
||||||
handled = true;
|
|
||||||
} else if (e.ctrlKey && e.key == "b") {
|
} else if (e.ctrlKey && e.key == "b") {
|
||||||
this.visuButt = !this.visuButt
|
this.visuButt = !this.visuButt
|
||||||
handled = true;
|
|
||||||
} else if (e.ctrlKey && e.key == "f") {
|
} else if (e.ctrlKey && e.key == "f") {
|
||||||
document.documentElement.requestFullscreen();
|
document.documentElement.requestFullscreen();
|
||||||
handled = true;
|
|
||||||
} else if (e.ctrlKey && e.key == "s") {
|
} else if (e.ctrlKey && e.key == "s") {
|
||||||
this.speak();
|
this.speak();
|
||||||
handled = true;
|
|
||||||
} else if (e.ctrlKey && e.key == "z") {
|
} else if (e.ctrlKey && e.key == "z") {
|
||||||
this.visuForm = !this.visuForm
|
this.visuForm = !this.visuForm
|
||||||
handled = true;
|
|
||||||
} else if (e.ctrlKey && e.key == "ArrowUp") {
|
} else if (e.ctrlKey && e.key == "ArrowUp") {
|
||||||
this.incPunt("home")
|
this.incPunt("home")
|
||||||
handled = true;
|
|
||||||
} else if (e.ctrlKey && e.key == "ArrowDown") {
|
} else if (e.ctrlKey && e.key == "ArrowDown") {
|
||||||
this.decPunt("home")
|
this.decPunt("home")
|
||||||
handled = true;
|
|
||||||
} else if (e.ctrlKey && e.key == "ArrowRight") {
|
} else if (e.ctrlKey && e.key == "ArrowRight") {
|
||||||
this.incSet("home")
|
this.incSet("home")
|
||||||
handled = true;
|
|
||||||
} else if (e.shiftKey && e.key == "ArrowUp") {
|
} else if (e.shiftKey && e.key == "ArrowUp") {
|
||||||
this.incPunt("guest")
|
this.incPunt("guest")
|
||||||
handled = true;
|
|
||||||
} else if (e.shiftKey && e.key == "ArrowDown") {
|
} else if (e.shiftKey && e.key == "ArrowDown") {
|
||||||
this.decPunt("guest")
|
this.decPunt("guest")
|
||||||
handled = true;
|
|
||||||
} else if (e.shiftKey && e.key == "ArrowRight") {
|
} else if (e.shiftKey && e.key == "ArrowRight") {
|
||||||
this.incSet("guest")
|
this.incSet("guest")
|
||||||
handled = true;
|
|
||||||
} else if (e.ctrlKey && e.key == "ArrowLeft") {
|
} else if (e.ctrlKey && e.key == "ArrowLeft") {
|
||||||
this.cambiaPalla()
|
this.sp.servHome = !this.sp.servHome
|
||||||
handled = true;
|
|
||||||
} else if (e.ctrlKey && (e.key == "c" || e.key == "C")) {
|
|
||||||
this.apriDialogCambiTeam("home")
|
|
||||||
handled = true;
|
|
||||||
} else if (e.shiftKey && (e.key == "c" || e.key == "C")) {
|
|
||||||
this.apriDialogCambiTeam("guest")
|
|
||||||
handled = true;
|
|
||||||
} else { return false }
|
} else { return false }
|
||||||
|
|
||||||
if (handled) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
198
src/style.css
198
src/style.css
@@ -1,17 +1,33 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
touch-action: pan-x pan-y;
|
touch-action: none;
|
||||||
height: 100%
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
min-width: 320px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh; /* Dynamic viewport height per mobile */
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -38,8 +54,13 @@ button:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.campo {
|
.campo {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -58,19 +79,6 @@ button:focus-visible {
|
|||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
.score-inline {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 3ch;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.serv-slot {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.tal {
|
.tal {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -93,23 +101,8 @@ button:focus-visible {
|
|||||||
float: left;
|
float: left;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
.punteggio-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.punt {
|
.punt {
|
||||||
font-size: 60vh;
|
font-size: 60vh;
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
min-height: 50vh;
|
|
||||||
min-width: 50vw;
|
|
||||||
max-width: 50vw;
|
|
||||||
overflow: hidden;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
.form {
|
.form {
|
||||||
font-size: 5vh;
|
font-size: 5vh;
|
||||||
@@ -151,114 +144,43 @@ button:focus-visible {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.campo-config {
|
/* Responsive mobile */
|
||||||
display: flex;
|
@media (max-width: 768px) {
|
||||||
flex-direction: column;
|
.hea {
|
||||||
align-items: center;
|
font-size: 4vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.campo-pallavolo {
|
.hea span {
|
||||||
border: 3px solid #999;
|
padding-left: 5px;
|
||||||
background-color: rgba(205, 133, 63, 0.25);
|
padding-right: 5px;
|
||||||
position: relative;
|
}
|
||||||
width: 220px;
|
|
||||||
height: 220px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fila-anteriore {
|
.hea img {
|
||||||
height: 33.33%;
|
width: 18px !important;
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fila-posteriore {
|
.punt {
|
||||||
height: 66.67%;
|
font-size: 45vh;
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.linea-tre-metri {
|
.form {
|
||||||
border-top: 2px solid #666;
|
font-size: 4vh;
|
||||||
width: 100%;
|
border-top: #fff dashed 15px;
|
||||||
height: 0;
|
padding-top: 30px;
|
||||||
margin: 0;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.cambi-rows {
|
.formdiv {
|
||||||
display: flex;
|
font-size: 15vh;
|
||||||
flex-direction: column;
|
}
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cambi-dialog {
|
.bot button {
|
||||||
padding: 8px 6px 2px;
|
font-size: 0.7em;
|
||||||
}
|
padding: 0.4em 0.8em;
|
||||||
|
margin-left: 3px;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.cambi-title {
|
.bot img {
|
||||||
text-align: center;
|
width: 20px !important;
|
||||||
font-weight: 800;
|
}
|
||||||
letter-spacing: 0.5px;
|
}
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cambi-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cambi-arrow {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cambi-input {
|
|
||||||
min-width: 48px;
|
|
||||||
max-width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cambi-input input,
|
|
||||||
.cambi-input .w-input__input,
|
|
||||||
.cambi-input .w-input__field {
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: #000;
|
|
||||||
text-align: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cambi-in input,
|
|
||||||
.cambi-in .w-input__input,
|
|
||||||
.cambi-in .w-input__field {
|
|
||||||
background: rgba(120, 200, 120, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cambi-out input,
|
|
||||||
.cambi-out .w-input__input,
|
|
||||||
.cambi-out .w-input__field {
|
|
||||||
background: rgba(200, 120, 120, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cambi-input input:focus,
|
|
||||||
.cambi-input .w-input__input:focus,
|
|
||||||
.cambi-input .w-input__field:focus {
|
|
||||||
border-color: rgba(255, 255, 255, 0.7);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { VitePWA } from 'vite-plugin-pwa'
|
|||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: process.env.NODE_ENV === 'production' ? '/segnap' : '/',
|
base: '/',
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
|||||||
Reference in New Issue
Block a user