@@ -394,3 +417,21 @@
+
+
+
diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss
index 13b6dd5..dc70074 100644
--- a/src/app/home/home.page.scss
+++ b/src/app/home/home.page.scss
@@ -953,3 +953,323 @@ ion-title {
color: #c0392b !important;
border-color: #c0392b !important;
}
+
+/* ==========================================================================
+ PWA INSTALLATION PROMOTIONS (Android & iOS)
+ ========================================================================== */
+
+/* 1. Android Installation Banner */
+.pwa-android-banner {
+ --background: rgba(255, 255, 255, 0.04);
+ background: rgba(255, 255, 255, 0.04);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-left: 4px solid var(--ion-color-secondary);
+ border-radius: 16px;
+ overflow: hidden;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ animation: slideInBanner 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.12);
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
+ }
+
+ .banner-inner {
+ display: flex;
+ align-items: center;
+ padding: 14px 16px;
+ gap: 12px;
+ }
+
+ .banner-icon-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(var(--ion-color-secondary-rgb), 0.12);
+ border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.2);
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+ flex-shrink: 0;
+
+ .banner-icon {
+ font-size: 1.5rem;
+ color: var(--ion-color-secondary);
+ }
+ }
+
+ .banner-text-wrapper {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ overflow: hidden;
+
+ .banner-title {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: white;
+ margin: 0;
+ letter-spacing: 0.2px;
+ }
+
+ .banner-subtitle {
+ font-size: 0.78rem;
+ line-height: 1.35;
+ color: rgba(255, 255, 255, 0.65);
+ margin: 0;
+ font-weight: 400;
+ }
+ }
+
+ .banner-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+
+ .install-action-btn {
+ --border-radius: 10px;
+ --box-shadow: 0 4px 10px rgba(var(--ion-color-secondary-rgb), 0.2);
+ font-weight: 700;
+ font-size: 0.8rem;
+ height: 32px;
+ margin: 0;
+ text-transform: none;
+ letter-spacing: 0.2px;
+ transition: transform 0.2s ease;
+
+ &:active {
+ transform: scale(0.95);
+ }
+ }
+
+ .dismiss-action-btn {
+ --color: rgba(255, 255, 255, 0.5);
+ --padding-start: 4px;
+ --padding-end: 4px;
+ margin: 0;
+ height: 32px;
+ width: 32px;
+
+ ion-icon {
+ font-size: 1.3rem;
+ }
+
+ &:active {
+ opacity: 0.7;
+ }
+ }
+ }
+}
+
+/* 2. iOS Safari Contextual Tooltip */
+.pwa-ios-tooltip-wrapper {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10000;
+ width: 90%;
+ max-width: 385px;
+ pointer-events: none;
+ filter: drop-shadow(0 12px 36px rgba(0, 0, 0, 0.45));
+ transition: bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+
+ &.has-player {
+ bottom: 84px; /* Float above active bottom player footer */
+ }
+
+ .pwa-ios-tooltip {
+ pointer-events: auto;
+ display: flex;
+ flex-direction: column;
+ background: rgba(26, 26, 46, 0.96) !important;
+ backdrop-filter: blur(25px);
+ -webkit-backdrop-filter: blur(25px);
+ border: 1.5px solid rgba(var(--ion-color-secondary-rgb), 0.25);
+ border-radius: 20px;
+ padding: 14px 16px;
+ position: relative;
+ box-shadow: inset 0 0 20px rgba(255, 255, 255, 0.02);
+ animation: floatTooltip 0.45s cubic-bezier(0.175, 0.885, 0.32, 1.275), bounceGently 3s ease-in-out infinite;
+
+ .tooltip-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 6px;
+
+ .tooltip-title {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--ion-color-secondary);
+ letter-spacing: 0.3px;
+ }
+
+ .dismiss-btn {
+ --color: rgba(255, 255, 255, 0.45);
+ --padding-start: 4px;
+ --padding-end: 4px;
+ margin: 0;
+ height: 24px;
+ width: 24px;
+
+ ion-icon {
+ font-size: 1.2rem;
+ }
+
+ &:active {
+ opacity: 0.7;
+ }
+ }
+ }
+
+ .tooltip-body {
+ .tooltip-text {
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: rgba(255, 255, 255, 0.85);
+ margin: 0;
+ font-weight: 400;
+
+ strong {
+ color: white;
+ font-weight: 600;
+ }
+
+ .safari-icon-inline {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ padding: 3px;
+ border-radius: 6px;
+ vertical-align: middle;
+ margin: 0 3px;
+ width: 20px;
+ height: 20px;
+
+ ion-icon {
+ font-size: 0.95rem;
+ color: #007aff; /* Apple System Blue */
+ }
+ }
+ }
+ }
+
+ .tooltip-arrow {
+ width: 0;
+ height: 0;
+ border-left: 12px solid transparent;
+ border-right: 12px solid transparent;
+ border-top: 12px solid rgba(26, 26, 46, 0.96);
+ position: absolute;
+ bottom: -11px;
+ left: 50%;
+ transform: translateX(-50%);
+
+ &::after {
+ content: '';
+ width: 0;
+ height: 0;
+ border-left: 12px solid transparent;
+ border-right: 12px solid transparent;
+ border-top: 12px solid rgba(var(--ion-color-secondary-rgb), 0.25);
+ position: absolute;
+ bottom: -1px;
+ left: -12px;
+ z-index: -1;
+ }
+ }
+ }
+}
+
+/* Animations */
+@keyframes slideInBanner {
+ from {
+ opacity: 0;
+ transform: translateY(-16px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes floatTooltip {
+ from {
+ opacity: 0;
+ transform: translate(-50%, 20px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translate(-50%, 0) scale(1);
+ }
+}
+
+@keyframes bounceGently {
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-5px);
+ }
+}
+
+/* High Contrast mode adaptations */
+:host-context(body.high-contrast) {
+ .pwa-android-banner {
+ background: #ffffff !important;
+ border: 2px solid #000000 !important;
+ border-left-width: 6px !important;
+
+ .banner-title, .banner-subtitle {
+ color: #000000 !important;
+ }
+ .banner-icon {
+ color: #000000 !important;
+ }
+ .banner-icon-wrapper {
+ background: rgba(0, 0, 0, 0.05) !important;
+ border-color: #000000 !important;
+ }
+ .install-action-btn {
+ --background: #000000 !important;
+ --color: #ffffff !important;
+ }
+ .dismiss-action-btn {
+ --color: #000000 !important;
+ }
+ }
+
+ .pwa-ios-tooltip {
+ background: #ffffff !important;
+ border: 2px solid #000000 !important;
+
+ .tooltip-title, .tooltip-text, .tooltip-text strong {
+ color: #000000 !important;
+ }
+ .dismiss-btn {
+ --color: #000000 !important;
+ }
+ .tooltip-arrow {
+ border-top-color: #ffffff !important;
+ &::after {
+ border-top-color: #000000 !important;
+ }
+ }
+ .safari-icon-inline {
+ background: rgba(0, 0, 0, 0.05) !important;
+ border-color: #000000 !important;
+ ion-icon {
+ color: #000000 !important;
+ }
+ }
+ }
+}
diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts
index b612ef4..adfdd7a 100644
--- a/src/app/home/home.page.ts
+++ b/src/app/home/home.page.ts
@@ -15,6 +15,7 @@ import { MyCantiService } from '../services/my-canti.service';
import { QrScannerComponent } from '../components/qr-scanner/qr-scanner.component';
import { CantiLettureService } from '../services/canti-letture.service';
import { ComunitaService } from '../services/comunita.service';
+import { environment } from '../../environments/environment';
@Component({
selector: 'app-home',
@@ -33,10 +34,14 @@ export class HomePage implements OnDestroy {
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | null>(null);
public loadedThumbs = new Set
();
public version = VERSION;
+ public appName = environment.appName;
public isAddingSongs = signal(false);
public reorderList = signal([]);
public limit = signal(30);
+ public showAndroidBanner = signal(false);
+ public showIosTooltip = signal(false);
+
public fontSize = signal(1.0);
public youtubePlayerService = inject(YoutubePlayerService);
public comunitaService = inject(ComunitaService);
@@ -110,23 +115,28 @@ export class HomePage implements OnDestroy {
const onlyMine = this.showOnlyMine();
const topTen = this.showTopTen();
const suggeriti = this.showSuggeriti();
-
- let list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()];
+ const activeIds = this.playlistService.activeListIds();
+ const selectionMode = this.playlistService.selectionMode();
- // Filter by Community if code is set and filter is active
+ // 1. Get base list depending on community filter
+ let list: any[] = [];
const comunitaCode = this.comunitaService.comunitaCode();
const comunitaIds = this.comunitaService.comunitaCantiIds();
+
if (comunitaCode && this.comunitaService.isFilterActive()) {
- // Include parish custom/unvalidated canti
- list = [...list, ...this.comunitaService.comunitaCantiPersonali()];
-
+ // Base is standard canti + my canti + community custom canti
+ list = [
+ ...this.cantiService.canti(),
+ ...this.myCantiService.myCanti(),
+ ...this.comunitaService.comunitaCantiPersonali()
+ ];
+
+ // Filter to only community canti
list = list.filter(c => {
return comunitaIds.includes(c.id_canti) || comunitaIds.includes(c.id);
});
- // Deduplicate: if a song exists in both validated and unvalidated lists,
- // the NON-VALIDATED (parish-customized) version always takes precedence,
- // so parishes can always personalize their songs even if never validated.
+ // Deduplicate: parish-customized versions take precedence
const seen = new Map();
for (const canto of list) {
const key = canto.id_canti || canto.id;
@@ -134,16 +144,14 @@ export class HomePage implements OnDestroy {
if (!existing) {
seen.set(key, canto);
} else {
- // Precedence to non-validated (parish custom) version:
- // overwrite only if current entry is validated and new one is non-validated
if (!existing.nonValidato && canto.nonValidato) {
seen.set(key, canto);
}
}
}
list = Array.from(seen.values());
-
- // Sort list by community song number (num_canto) ASC!
+
+ // Sort by community song number (num_canto)
const cantiInfo = this.comunitaService.comunitaCantiInfo();
list.sort((a, b) => {
const infoA = cantiInfo.find(x => x.id_canti === a.id_canti || x.id_canti === Number(a.id));
@@ -152,49 +160,77 @@ export class HomePage implements OnDestroy {
const numB = infoB ? Number(infoB.num_canto) : 999999;
return numA - numB;
});
+ } else {
+ // General context: only standard canti + my canti
+ list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()];
}
- if (onlyMine) {
- list = this.myCantiService.myCanti();
+ // 2. Cumulative filtering
+
+ // Filter by selectionMode / reorderList if in selection mode and not adding songs
+ if (selectionMode && !this.isAddingSongs()) {
+ return this.reorderList();
}
-
+
+ // A. Filter by Playlist (if activeIds is present)
+ if (activeIds.length > 0 && !selectionMode) {
+ list = activeIds
+ .map(id => list.find(c => c.id === id))
+ .filter((c): c is any => !!c);
+ }
+
+ // B. Filter by "Only Mine" (Miei)
+ if (onlyMine) {
+ const myIds = new Set(this.myCantiService.myCanti().map(c => c.id));
+ list = list.filter(c => myIds.has(c.id));
+ }
+
+ // C. Filter by Liturgical Moment
if (litId !== null) {
list = list.filter(c => c.id_momenti?.includes(litId));
}
+
+ // D. Filter by Thematic Moment
if (temId !== null) {
list = list.filter(c => c.id_momenti?.includes(temId));
}
+ // E. Filter by Suggested (Suggeriti)
if (suggeriti) {
const suggMap = this.cantiLettureService.suggestionsMap();
list = list.filter(c => suggMap.has(c.id_canti));
}
- // Special List handling (from QR Code or Saved Playlists)
- const activeIds = this.playlistService.activeListIds();
- const selectionMode = this.playlistService.selectionMode();
-
- // If we are in selection mode, only show restricted list if NOT adding songs
- if (selectionMode && !this.isAddingSongs()) {
- return this.reorderList();
+ // F. Filter by Search Query
+ if (query) {
+ const processedQuery = query.replace(/\buno\b/g, '1');
+ const numberMatchPattern = processedQuery.match(/^(?:numero|nr\.?)\s*(\d+)$/);
+
+ if (numberMatchPattern) {
+ const targetId = numberMatchPattern[1];
+ list = list.filter(c => {
+ const commNum = this.getCommunitySongNumber(c);
+ return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
+ });
+ } else {
+ list = list.filter(c => {
+ const titleMatch = this.normalize(c.titolo).includes(query);
+ const authorMatch = this.normalize(c.autore).includes(query);
+ const commNum = this.getCommunitySongNumber(c);
+ const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query);
+
+ const lyricsPlain = (c.testo || '')
+ .replace(/{.*?}/g, '')
+ .replace(/\n/g, ' ');
+ const lyricsMatch = this.normalize(lyricsPlain).includes(query);
+
+ return titleMatch || authorMatch || lyricsMatch || numberMatch;
+ });
+ }
}
- // If we have an active playlist and NOT in selection mode, show ONLY those songs
- if (activeIds.length > 0 && !selectionMode) {
- // Filter only songs in the special list and KEEP THE ORDER
- return activeIds
- .map(id => list.find(c => c.id === id))
- .filter((c): c is any => !!c);
- }
-
- if (suggeriti) {
- const suggMap = this.cantiLettureService.suggestionsMap();
- list = list.sort((a, b) => {
- const pesoA = suggMap.get(a.id_canti) || 0;
- const pesoB = suggMap.get(b.id_canti) || 0;
- return pesoB - pesoA;
- });
- } else if (topTen) {
+ // G. Sorting/Ordering
+ if (topTen) {
const eseguiti = this.cantiService.cantiEseguiti();
const eseguitiMap = new Map();
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num));
@@ -204,37 +240,16 @@ export class HomePage implements OnDestroy {
const numB = eseguitiMap.get(b.id_canti) || 0;
return numB - numA;
});
- }
-
- if (!query) return list;
-
- // Converte "uno" in "1" per facilitare la ricerca vocale (es. "numero uno")
- const processedQuery = query.replace(/\buno\b/g, '1');
-
- // Se la ricerca inizia con "numero" o "nr", filtra esattamente per id_canti/comunita number
- const numberMatchPattern = processedQuery.match(/^(?:numero|nr\.?)\s*(\d+)$/);
- if (numberMatchPattern) {
- const targetId = numberMatchPattern[1];
- return list.filter(c => {
- const commNum = this.getCommunitySongNumber(c);
- return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
+ } else if (suggeriti) {
+ const suggMap = this.cantiLettureService.suggestionsMap();
+ list = list.sort((a, b) => {
+ const pesoA = suggMap.get(a.id_canti) || 0;
+ const pesoB = suggMap.get(b.id_canti) || 0;
+ return pesoB - pesoA;
});
}
- return list.filter(c => {
- const titleMatch = this.normalize(c.titolo).includes(query);
- const authorMatch = this.normalize(c.autore).includes(query);
- const commNum = this.getCommunitySongNumber(c);
- const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query);
-
- // Strip tags like {Chorus} and newlines from lyrics before searching
- const lyricsPlain = (c.testo || '')
- .replace(/{.*?}/g, '')
- .replace(/\n/g, ' ');
- const lyricsMatch = this.normalize(lyricsPlain).includes(query);
-
- return titleMatch || authorMatch || lyricsMatch || numberMatch;
- });
+ return list;
});
public visibleCanti = computed(() => {
@@ -242,6 +257,49 @@ export class HomePage implements OnDestroy {
});
constructor() {
+ // Check if install prompts should be visible
+ const androidDismissed = localStorage.getItem('pwa-android-dismissed') === 'true';
+ const iosDismissed = localStorage.getItem('pwa-ios-dismissed') === 'true';
+
+ this.showAndroidBanner.set(
+ this.settingsService.isAndroid() &&
+ !this.settingsService.isStandalone() &&
+ !androidDismissed
+ );
+
+ this.showIosTooltip.set(
+ this.settingsService.isIos() &&
+ !this.settingsService.isStandalone() &&
+ !iosDismissed
+ );
+
+ // Track initial community filter state to avoid clearing during the initial run of the effect
+ let prevFilterActive = this.comunitaService.isFilterActive();
+ let prevComunitaCode = this.comunitaService.comunitaCode();
+
+ // Automatically reset all other filters and active playlist when toggling the community filter or changing community
+ effect(() => {
+ const active = this.comunitaService.isFilterActive();
+ const code = this.comunitaService.comunitaCode();
+
+ if (active !== prevFilterActive || code !== prevComunitaCode) {
+ prevFilterActive = active;
+ prevComunitaCode = code;
+
+ this.selectedLiturgico.set(null);
+ this.selectedTematico.set(null);
+ this.showOnlyMine.set(false);
+ this.showTopTen.set(false);
+ this.showSuggeriti.set(false);
+ this.playlistService.activeListIds.set([]);
+ this.playlistService.activeListName.set(null);
+ this.playlistService.activePlaylistId.set(null);
+ this.searchQuery.set('');
+ this.activeFilterType.set(null);
+ this.limit.set(10);
+ }
+ }, { allowSignalWrites: true });
+
// Sync speech recognition results to search query
effect(() => {
const transcript = this.audioEngine.searchTranscript();
@@ -307,6 +365,34 @@ export class HomePage implements OnDestroy {
}, { allowSignalWrites: true });
}
+ dismissAndroidBanner(event?: Event) {
+ if (event) event.stopPropagation();
+ this.showAndroidBanner.set(false);
+ localStorage.setItem('pwa-android-dismissed', 'true');
+ }
+
+ dismissIosTooltip(event?: Event) {
+ if (event) event.stopPropagation();
+ this.showIosTooltip.set(false);
+ localStorage.setItem('pwa-ios-dismissed', 'true');
+ }
+
+ async installAndroidPwa() {
+ if (this.settingsService.deferredPrompt()) {
+ await this.settingsService.installPwa();
+ this.dismissAndroidBanner();
+ } else {
+ const toast = await this.toastCtrl.create({
+ message: 'Tocca il menu del browser (i tre puntini in alto a destra) e seleziona "Aggiungi a schermata Home" o "Installa app".',
+ duration: 6000,
+ position: 'bottom',
+ color: 'secondary',
+ buttons: [{ text: 'OK', role: 'cancel' }]
+ });
+ await toast.present();
+ }
+ }
+
handleImport(base64: string) {
try {
const decoded = decodeURIComponent(escape(atob(base64)));
@@ -382,10 +468,6 @@ export class HomePage implements OnDestroy {
toggleOnlyMine() {
this.showOnlyMine.update(v => !v);
- if (this.showOnlyMine()) {
- this.selectedLiturgico.set(null);
- this.selectedTematico.set(null);
- }
this.limit.set(10);
}
@@ -401,7 +483,6 @@ export class HomePage implements OnDestroy {
} else if (type === 'tematico') {
this.selectedTematico.update(cur => cur === id ? null : id);
}
- this.showOnlyMine.set(false);
this.activeFilterType.set(null);
this.limit.set(10);
}
@@ -510,10 +591,6 @@ export class HomePage implements OnDestroy {
this.playlistService.activePlaylistId.set(pl.id);
this.activeFilterType.set(null);
this.limit.set(50);
- // Clear other filters
- this.selectedLiturgico.set(null);
- this.selectedTematico.set(null);
- this.showOnlyMine.set(false);
}
toggleTopTen() {
@@ -580,7 +657,7 @@ export class HomePage implements OnDestroy {
const numId = parseInt(id, 10);
if (isNaN(numId)) return null;
const es = this.cantiService.cantiEseguiti().find(x => x.id_canti === numId);
- return es ? es.num : null;
+ return es ? es.num : 0;
}
onImgLoad(id: string) {
diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html
index a373b7d..65689bf 100644
--- a/src/app/pages/settings/settings.page.html
+++ b/src/app/pages/settings/settings.page.html
@@ -16,7 +16,7 @@
Installa Applicazione
- Aggiungi CantiCristiani alla schermata home
+ Aggiungi {{ appName }} alla schermata home
Installa
diff --git a/src/app/pages/settings/settings.page.ts b/src/app/pages/settings/settings.page.ts
index 44d550e..45a560e 100644
--- a/src/app/pages/settings/settings.page.ts
+++ b/src/app/pages/settings/settings.page.ts
@@ -37,6 +37,7 @@ export class SettingsPage {
public version = VERSION;
public contactEmail = environment.contactEmail;
+ public appName = environment.appName;
public showIosInstructions = false;
constructor() {}
diff --git a/src/app/services/canti-letture.service.ts b/src/app/services/canti-letture.service.ts
index 2e90d25..b65cd00 100644
--- a/src/app/services/canti-letture.service.ts
+++ b/src/app/services/canti-letture.service.ts
@@ -4,8 +4,10 @@ import { Storage } from '@ionic/storage-angular';
import { firstValueFrom } from 'rxjs';
export interface Suggestion {
- id_canto: number;
- peso: number;
+ id_canto?: number;
+ id_canti?: number;
+ peso?: number;
+ score?: number;
}
export interface MassDetails {
@@ -62,9 +64,13 @@ export class CantiLettureService {
if (Array.isArray(mass.suggestions)) {
// Legacy flat suggestions format
mass.suggestions.forEach(s => {
- if (s && s.id_canto) {
- newMap.set(s.id_canto, s.peso);
- newMomentsMap.set(s.id_canto, ['Generale']);
+ if (s) {
+ const id = s.id_canto || (s as any).id_canti;
+ const weight = s.peso !== undefined ? s.peso : (s as any).score;
+ if (id) {
+ newMap.set(id, weight);
+ newMomentsMap.set(id, ['Generale']);
+ }
}
});
} else {
@@ -73,17 +79,21 @@ export class CantiLettureService {
const momentSuggestions = (mass.suggestions as any)[moment];
if (Array.isArray(momentSuggestions)) {
momentSuggestions.forEach(s => {
- if (s && s.id_canto) {
- // Save max weight
- const existingWeight = newMap.get(s.id_canto) || 0;
- if (s.peso > existingWeight) {
- newMap.set(s.id_canto, s.peso);
- }
+ if (s) {
+ const id = s.id_canto || (s as any).id_canti;
+ const weight = s.peso !== undefined ? s.peso : (s as any).score;
+ if (id) {
+ // Save max weight
+ const existingWeight = newMap.get(id) || 0;
+ if (weight > existingWeight) {
+ newMap.set(id, weight);
+ }
- // Save liturgical moment
- const existingMoments = newMomentsMap.get(s.id_canto) || [];
- if (!existingMoments.includes(moment)) {
- newMomentsMap.set(s.id_canto, [...existingMoments, moment]);
+ // Save liturgical moment
+ const existingMoments = newMomentsMap.get(id) || [];
+ if (!existingMoments.includes(moment)) {
+ newMomentsMap.set(id, [...existingMoments, moment]);
+ }
}
}
});
@@ -129,8 +139,12 @@ export class CantiLettureService {
let fetchedData: CantiLettureData | null = null;
const isProduction = window.location.hostname.includes('canticristiani.it');
- const primaryUrl = isProduction ? `${window.location.origin}${this.SECURE_JSON_URL}` : this.JSON_URL;
- const fallbackUrl = isProduction ? this.JSON_URL : `${window.location.origin}${this.SECURE_JSON_URL}`;
+ const primaryUrl = isProduction
+ ? `${window.location.origin}${this.SECURE_JSON_URL}`
+ : 'https://www.canticristiani.it/api/cantiletture.json';
+ const fallbackUrl = isProduction
+ ? 'https://www.canticristiani.it/api/cantiletture.json'
+ : this.JSON_URL;
try {
console.log('Fetching mass data from primary URL:', primaryUrl);
@@ -182,7 +196,11 @@ export class CantiLettureService {
// Always select today's mass if available on load, else find closest future date
if (massesList.length > 0) {
- const todayStr = new Date().toISOString().split('T')[0];
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, '0');
+ const day = String(now.getDate()).padStart(2, '0');
+ const todayStr = `${year}-${month}-${day}`;
const match = massesList.find(m => m.date === todayStr);
if (match) {
this.selectedMassDate.set(match.date);
diff --git a/src/app/services/my-canti.spec.ts b/src/app/services/my-canti.spec.ts
index fef0939..1c790a7 100644
--- a/src/app/services/my-canti.spec.ts
+++ b/src/app/services/my-canti.spec.ts
@@ -1,13 +1,37 @@
import { TestBed } from '@angular/core/testing';
+import { MyCantiService } from './my-canti.service';
+import { Storage } from '@ionic/storage-angular';
+import { CantiService } from './canti.service';
+import { ToastController } from '@ionic/angular';
-import { MyCanti } from './my-canti';
-
-describe('MyCanti', () => {
- let service: MyCanti;
+describe('MyCantiService', () => {
+ let service: MyCantiService;
+ let mockStorage: any;
+ let mockCantiService: any;
+ let mockToastCtrl: any;
beforeEach(() => {
- TestBed.configureTestingModule({});
- service = TestBed.inject(MyCanti);
+ mockStorage = jasmine.createSpyObj('Storage', ['create', 'get', 'set']);
+ mockStorage.get.and.returnValue(Promise.resolve([]));
+ mockStorage.set.and.returnValue(Promise.resolve());
+
+ mockCantiService = jasmine.createSpyObj('CantiService', ['getStorage']);
+ mockCantiService.getStorage.and.returnValue(mockStorage);
+
+ mockToastCtrl = jasmine.createSpyObj('ToastController', ['create']);
+ mockToastCtrl.create.and.returnValue(Promise.resolve({
+ present: jasmine.createSpy('present').and.returnValue(Promise.resolve())
+ }));
+
+ TestBed.configureTestingModule({
+ providers: [
+ MyCantiService,
+ { provide: Storage, useValue: mockStorage },
+ { provide: CantiService, useValue: mockCantiService },
+ { provide: ToastController, useValue: mockToastCtrl }
+ ]
+ });
+ service = TestBed.inject(MyCantiService);
});
it('should be created', () => {
diff --git a/src/app/services/playlist.service.spec.ts b/src/app/services/playlist.service.spec.ts
new file mode 100644
index 0000000..7059658
--- /dev/null
+++ b/src/app/services/playlist.service.spec.ts
@@ -0,0 +1,74 @@
+import { TestBed } from '@angular/core/testing';
+import { PlaylistService } from './playlist.service';
+import { Storage } from '@ionic/storage-angular';
+import { CantiService } from './canti.service';
+import { ComunitaService } from './comunita.service';
+import { signal } from '@angular/core';
+
+describe('PlaylistService', () => {
+ let service: PlaylistService;
+ let mockStorage: any;
+ let mockCantiService: any;
+ let mockComunitaService: any;
+
+ beforeEach(async () => {
+ mockStorage = jasmine.createSpyObj('Storage', ['create', 'get', 'set']);
+ mockStorage.create.and.returnValue(Promise.resolve(mockStorage));
+ mockStorage.get.and.callFake((key: string) => {
+ if (key === 'playlists') {
+ return Promise.resolve([{ id: '1', name: 'Playlist Generale', ids: ['101', '102'] }]);
+ }
+ if (key === 'playlists_comunita_123456') {
+ return Promise.resolve([{ id: '2', name: 'Playlist Comunita 123', ids: ['103'] }]);
+ }
+ return Promise.resolve(null);
+ });
+ mockStorage.set.and.returnValue(Promise.resolve());
+
+ mockCantiService = jasmine.createSpyObj('CantiService', ['canti']);
+ mockCantiService.canti.and.returnValue([]);
+
+ mockComunitaService = {
+ comunitaCode: signal(''),
+ isFilterActive: signal(false),
+ comunitaScalette: signal([]),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ PlaylistService,
+ { provide: Storage, useValue: mockStorage },
+ { provide: CantiService, useValue: mockCantiService },
+ { provide: ComunitaService, useValue: mockComunitaService }
+ ]
+ });
+
+ service = TestBed.inject(PlaylistService);
+ // Simulate init() completion
+ await service.init();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should load general playlists when community is not active', async () => {
+ mockComunitaService.isFilterActive.set(false);
+ mockComunitaService.comunitaCode.set('');
+
+ await service.loadPlaylistsForCurrentContext();
+
+ expect(service.playlists().length).toBe(1);
+ expect(service.playlists()[0].name).toBe('Playlist Generale');
+ });
+
+ it('should load community-specific playlists when community is active', async () => {
+ mockComunitaService.comunitaCode.set('123456');
+ mockComunitaService.isFilterActive.set(true);
+
+ await service.loadPlaylistsForCurrentContext();
+
+ expect(service.playlists().length).toBe(1);
+ expect(service.playlists()[0].name).toBe('Playlist Comunita 123');
+ });
+});
diff --git a/src/app/services/playlist.service.ts b/src/app/services/playlist.service.ts
index fe46ea5..42729d4 100644
--- a/src/app/services/playlist.service.ts
+++ b/src/app/services/playlist.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, signal, inject, computed } from '@angular/core';
+import { Injectable, signal, inject, computed, effect } from '@angular/core';
import { Storage } from '@ionic/storage-angular';
import { Canto, CantiService } from './canti.service';
import { ComunitaService } from './comunita.service';
@@ -50,19 +50,42 @@ export class PlaylistService {
constructor() {
this.init();
+
+ // Watch for context changes to dynamically reload the correct playlists
+ effect(() => {
+ // Trigger effect on changes to these signals
+ this.comunitaService.comunitaCode();
+ this.comunitaService.isFilterActive();
+
+ // Load the playlists
+ this.loadPlaylistsForCurrentContext();
+ }, { allowSignalWrites: true });
+ }
+
+ private getPlaylistsStorageKey(): string {
+ const code = this.comunitaService.comunitaCode();
+ const isCommunityActive = this.comunitaService.isFilterActive();
+ if (code && isCommunityActive) {
+ return `playlists_comunita_${code}`;
+ }
+ return 'playlists';
}
async init() {
const storage = await this.storage.create();
this._storage = storage;
- const saved = await this._storage.get('playlists');
- if (saved) {
- this.playlists.set(saved);
- }
- const last = await this._storage?.get('lastPlaylist');
- if (last) {
- this.lastPlaylist.set(last);
- }
+ await this.loadPlaylistsForCurrentContext();
+ }
+
+ async loadPlaylistsForCurrentContext() {
+ if (!this._storage) return;
+ const key = this.getPlaylistsStorageKey();
+ const saved = await this._storage.get(key);
+ this.playlists.set(saved || []);
+
+ const lastKey = `lastPlaylist_${key}`;
+ const last = await this._storage.get(lastKey);
+ this.lastPlaylist.set(last || null);
}
toggleSelectionMode() {
@@ -92,6 +115,8 @@ export class PlaylistService {
}
async savePlaylist(name: string, ids: string[]) {
+ const key = this.getPlaylistsStorageKey();
+ const lastKey = `lastPlaylist_${key}`;
const editId = this.activePlaylistId();
let newPlaylist: any;
@@ -116,8 +141,8 @@ export class PlaylistService {
this.lastPlaylist.set(newPlaylist);
this.activeListIds.set(ids);
this.activeListName.set(name);
- await this._storage?.set('playlists', this.playlists());
- await this._storage?.set('lastPlaylist', newPlaylist);
+ await this._storage?.set(key, this.playlists());
+ await this._storage?.set(lastKey, newPlaylist);
// Reset selection mode, IDs and editing state after saving
this.selectedIds.set(new Set());
@@ -126,8 +151,9 @@ export class PlaylistService {
}
async deletePlaylist(id: string) {
+ const key = this.getPlaylistsStorageKey();
this.playlists.update(p => p.filter(pl => pl.id !== id));
- await this._storage?.set('playlists', this.playlists());
+ await this._storage?.set(key, this.playlists());
}
async generateQR(ids: string[], name: string): Promise {
@@ -148,7 +174,7 @@ export class PlaylistService {
const base64 = btoa(unescape(encodeURIComponent(data)));
// Always use the production URL for sharing links as requested
- const productionUrl = 'https://www.canticristiani.it/ionic';
+ const productionUrl = 'https://www.canticristiani.it';
return `${productionUrl}/?import=${base64}`;
}
diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts
index 81ebff0..15d19c6 100644
--- a/src/app/services/settings.service.ts
+++ b/src/app/services/settings.service.ts
@@ -5,14 +5,14 @@ import { Injectable, signal, effect } from '@angular/core';
})
export class SettingsService {
/** Default mode for viewing songs: true = chords, false = text only */
- public showChordsDefault = signal(false);
+ public showChordsDefault = signal(true);
/** UI Fullscreen mode (lyrics only): true = active */
public fullscreenMode = signal(false);
/** Browser Fullscreen state: true = fullscreen active */
public browserFullscreen = signal(
- !!(
+ typeof document !== 'undefined' && !!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
@@ -27,25 +27,25 @@ export class SettingsService {
public showEditor = signal(false);
/** Schermo sempre acceso: true = attiva Screen Wake Lock */
- public keepScreenOn = signal(false);
+ public keepScreenOn = signal(true);
/** Funzionalità Comunità: true = il chip comunità è visibile nella home */
- public comunitaEnabled = signal(true);
+ public comunitaEnabled = signal(false);
/** Invio dati statistici: true = invia pacchetto dati statistici */
public invioDatiStatistici = signal(false);
/** Visualizza tag sotto autore nella lista canti: true = attivo */
- public showTagsInList = signal(false);
+ public showTagsInList = signal(true);
/** Visualizza data update sotto autore nella lista canti: true = attivo */
- public showUpdateDate = signal(false);
+ public showUpdateDate = signal(true);
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
- public enableStandardAutoscroll = signal(false);
+ public enableStandardAutoscroll = signal(true);
/** Attiva autoscroll acustico nel dettaglio canto: true = attivo */
- public enableAcousticAutoscroll = signal(true);
+ public enableAcousticAutoscroll = signal(false);
private wakeLock: any = null;
@@ -54,6 +54,7 @@ export class SettingsService {
public showInstallButton = signal(false);
public isStandalone = signal(false);
public isIos = signal(false);
+ public isAndroid = signal(false);
constructor() {
// Detect PWA status
@@ -65,6 +66,9 @@ export class SettingsService {
this.isIos.set(
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
);
+ this.isAndroid.set(
+ /Android/i.test(navigator.userAgent)
+ );
} catch (e) {
console.warn('Browser environment does not support display-mode media query');
}
@@ -84,59 +88,103 @@ export class SettingsService {
this.isStandalone.set(true);
console.log('PWA was installed');
});
+
+ // Migration: force settings defaults once for existing users to match the new rules
+ const migrationKey = 'defaults-migrated-20260521';
+ if (localStorage.getItem(migrationKey) !== 'true') {
+ localStorage.setItem('show-chords-default', 'true');
+ localStorage.setItem('fullscreen-mode', this.isIos().toString());
+ localStorage.setItem('show-editor', 'false');
+ localStorage.setItem('auto-advance', 'true');
+ localStorage.setItem('keep-screen-on', 'true');
+ localStorage.setItem('comunita-enabled', 'false');
+ localStorage.setItem('invio-dati-statistici', 'false');
+ localStorage.setItem('show-tags-in-list', 'true');
+ localStorage.setItem('show-update-date', 'true');
+ localStorage.setItem('enable-standard-autoscroll', 'true');
+ localStorage.setItem('enable-acoustic-autoscroll', 'false');
+
+ // ThemeService high contrast default
+ localStorage.setItem('high-contrast', 'true');
+
+ localStorage.setItem(migrationKey, 'true');
+ }
+
const savedChords = localStorage.getItem('show-chords-default');
if (savedChords !== null) {
this.showChordsDefault.set(savedChords === 'true');
+ } else {
+ this.showChordsDefault.set(true);
}
const savedFullscreen = localStorage.getItem('fullscreen-mode');
if (savedFullscreen !== null) {
this.fullscreenMode.set(savedFullscreen === 'true');
+ } else {
+ this.fullscreenMode.set(this.isIos());
}
const savedEditor = localStorage.getItem('show-editor');
if (savedEditor !== null) {
this.showEditor.set(savedEditor === 'true');
+ } else {
+ this.showEditor.set(false);
}
const savedAutoAdvance = localStorage.getItem('auto-advance');
if (savedAutoAdvance !== null) {
this.autoAdvance.set(savedAutoAdvance === 'true');
+ } else {
+ this.autoAdvance.set(true);
}
const savedKeepScreenOn = localStorage.getItem('keep-screen-on');
if (savedKeepScreenOn !== null) {
this.keepScreenOn.set(savedKeepScreenOn === 'true');
+ } else {
+ this.keepScreenOn.set(true);
}
const savedComunitaEnabled = localStorage.getItem('comunita-enabled');
if (savedComunitaEnabled !== null) {
this.comunitaEnabled.set(savedComunitaEnabled === 'true');
+ } else {
+ this.comunitaEnabled.set(false);
}
const savedStats = localStorage.getItem('invio-dati-statistici');
if (savedStats !== null) {
this.invioDatiStatistici.set(savedStats === 'true');
+ } else {
+ this.invioDatiStatistici.set(false);
}
const savedShowTags = localStorage.getItem('show-tags-in-list');
if (savedShowTags !== null) {
this.showTagsInList.set(savedShowTags === 'true');
+ } else {
+ this.showTagsInList.set(true);
}
const savedShowUpdateDate = localStorage.getItem('show-update-date');
if (savedShowUpdateDate !== null) {
this.showUpdateDate.set(savedShowUpdateDate === 'true');
+ } else {
+ this.showUpdateDate.set(true);
}
const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
if (savedStandardAutoscroll !== null) {
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
+ } else {
+ this.enableStandardAutoscroll.set(true);
}
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll');
if (savedAcousticAutoscroll !== null) {
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true');
+ } else {
+ this.enableAcousticAutoscroll.set(false);
}
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
diff --git a/src/app/services/theme.service.ts b/src/app/services/theme.service.ts
index 0ac1853..573b5bd 100644
--- a/src/app/services/theme.service.ts
+++ b/src/app/services/theme.service.ts
@@ -4,24 +4,26 @@ import { Injectable, signal, effect } from '@angular/core';
providedIn: 'root'
})
export class ThemeService {
- public highContrast = signal(false);
+ public highContrast = signal(true);
constructor() {
// Load from localStorage
const saved = localStorage.getItem('high-contrast');
- if (saved === 'true') {
- this.highContrast.set(true);
+ if (saved !== null) {
+ this.highContrast.set(saved === 'true');
} else {
- this.highContrast.set(false);
+ this.highContrast.set(true);
}
// Effect to apply class to body
effect(() => {
const isHigh = this.highContrast();
- if (isHigh) {
- document.body.classList.add('high-contrast');
- } else {
- document.body.classList.remove('high-contrast');
+ if (typeof document !== 'undefined' && document.body) {
+ if (isHigh) {
+ document.body.classList.add('high-contrast');
+ } else {
+ document.body.classList.remove('high-contrast');
+ }
}
localStorage.setItem('high-contrast', isHigh.toString());
});
diff --git a/src/app/version.ts b/src/app/version.ts
index bd32f35..b7db90d 100644
--- a/src/app/version.ts
+++ b/src/app/version.ts
@@ -1 +1 @@
-export const VERSION = '2026.05.20.1624';
+export const VERSION = '2026.05.22.0110';
diff --git a/src/assets/icons/icon-128x128.png b/src/assets/icons/icon-128x128.png
index b4f8114..f9375e1 100644
Binary files a/src/assets/icons/icon-128x128.png and b/src/assets/icons/icon-128x128.png differ
diff --git a/src/assets/icons/icon-144x144.png b/src/assets/icons/icon-144x144.png
index b4f8114..37894af 100644
Binary files a/src/assets/icons/icon-144x144.png and b/src/assets/icons/icon-144x144.png differ
diff --git a/src/assets/icons/icon-152x152.png b/src/assets/icons/icon-152x152.png
index b4f8114..a856095 100644
Binary files a/src/assets/icons/icon-152x152.png and b/src/assets/icons/icon-152x152.png differ
diff --git a/src/assets/icons/icon-192x192.png b/src/assets/icons/icon-192x192.png
index b4f8114..750473c 100644
Binary files a/src/assets/icons/icon-192x192.png and b/src/assets/icons/icon-192x192.png differ
diff --git a/src/assets/icons/icon-384x384.png b/src/assets/icons/icon-384x384.png
index b4f8114..d05a3d5 100644
Binary files a/src/assets/icons/icon-384x384.png and b/src/assets/icons/icon-384x384.png differ
diff --git a/src/assets/icons/icon-512x512.png b/src/assets/icons/icon-512x512.png
index b4f8114..f5a59bc 100644
Binary files a/src/assets/icons/icon-512x512.png and b/src/assets/icons/icon-512x512.png differ
diff --git a/src/assets/icons/icon-72x72.png b/src/assets/icons/icon-72x72.png
index b4f8114..61c6986 100644
Binary files a/src/assets/icons/icon-72x72.png and b/src/assets/icons/icon-72x72.png differ
diff --git a/src/assets/icons/icon-96x96.png b/src/assets/icons/icon-96x96.png
index b4f8114..81dea00 100644
Binary files a/src/assets/icons/icon-96x96.png and b/src/assets/icons/icon-96x96.png differ
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
index b04cd0e..fceaae4 100644
--- a/src/environments/environment.prod.ts
+++ b/src/environments/environment.prod.ts
@@ -1,4 +1,5 @@
export const environment = {
production: true,
- contactEmail: 'info@canticristiani.it'
+ contactEmail: 'info@canticristiani.it',
+ appName: 'CantiCristiani'
};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index b190756..2321f7c 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -4,7 +4,8 @@
export const environment = {
production: false,
- contactEmail: 'info@canticristiani.it'
+ contactEmail: 'info@canticristiani.it',
+ appName: 'CantiCristiani'
};
/*
diff --git a/src/main.ts b/src/main.ts
index d8ea2c3..91ec6da 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,12 @@
+import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
+import { environment } from './environments/environment';
+
+if (environment.production) {
+ enableProdMode();
+}
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.log(err));