import { DatePipe } from '@angular/common'; import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { finalize } from 'rxjs'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { AuthService } from '../../auth/auth.service'; import { getAuthErrorMessage } from '../../auth/error-message'; import { PublicUserSearchResult } from '../../auth/auth.models'; import { OnboardingService } from '../../onboarding/onboarding.service'; import { ListItemSuggestion, ListRealtimeEvent, UserList, UserListItem, } from '../lists.models'; import { ListsRealtimeService } from '../lists-realtime.service'; import { ListsService } from '../lists.service'; @Component({ selector: 'app-list-detail', imports: [ DatePipe, ReactiveFormsModule, RouterLink, MatButtonModule, MatCardModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatSnackBarModule, ], templateUrl: './list-detail.component.html', styleUrl: './list-detail.component.scss', }) export class ListDetailComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly formBuilder = inject(NonNullableFormBuilder); private readonly authService = inject(AuthService); private readonly listsService = inject(ListsService); private readonly listsRealtimeService = inject(ListsRealtimeService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly snackBar = inject(MatSnackBar); private readonly onboarding = inject(OnboardingService); protected readonly list = signal(null); protected readonly isCreateMode = signal(false); protected readonly loading = signal(true); protected readonly saving = signal(false); protected readonly editing = signal(false); protected readonly addingItem = signal(false); protected readonly loadingSuggestions = signal(false); protected readonly suggestionsLoaded = signal(false); protected readonly errorMessage = signal(null); protected readonly updatingItemId = signal(null); protected readonly addingSuggestionTitle = signal(null); protected readonly itemSuggestions = signal([]); protected readonly shareSearchTerm = signal(''); protected readonly shareSearchResults = signal([]); protected readonly searchingUsers = signal(false); protected readonly sharingUserId = signal(null); protected readonly removingShareUserId = signal(null); protected readonly canEditItems = computed(() => Boolean(this.list()?.id)); protected readonly canManageShares = computed( () => this.list()?.accessRole === 'owner' && !this.isCreateMode(), ); protected readonly showShareControls = computed( () => this.canManageShares() && this.showEditor(), ); protected readonly availableShareSearchResults = computed(() => { const list = this.list(); const collaboratorIds = new Set( list?.collaborators.map((collaborator) => collaborator.id) ?? [], ); return this.shareSearchResults().filter( (user) => user.id !== list?.ownerId && !collaboratorIds.has(user.id), ); }); protected readonly showEditor = computed(() => this.isCreateMode() || this.editing()); protected readonly listForm = this.formBuilder.group({ name: ['', [Validators.required]], description: [''], }); protected readonly itemForm = this.formBuilder.group({ title: ['', [Validators.required]], required: [true], }); ngOnInit(): void { this.isCreateMode.set(this.listId() === null); if (this.isCreateMode()) { this.loading.set(false); this.editing.set(true); this.listForm.reset({ name: '', description: '' }); return; } const listId = this.listId(); if (listId) { this.onboarding.listOpened(listId); this.subscribeToRealtime(listId); } this.loadList(); } protected loadList(): void { const listId = this.listId(); if (!listId) { this.errorMessage.set('Liste wurde nicht gefunden.'); this.loading.set(false); return; } this.loading.set(true); this.errorMessage.set(null); this.listsService.getList(listId).subscribe({ next: (list) => { this.setList(list); this.loading.set(false); }, error: (error: unknown) => { this.errorMessage.set(getAuthErrorMessage(error)); this.loading.set(false); }, }); } protected saveList(): void { const listId = this.listId(); if (this.listForm.invalid) { this.listForm.markAllAsTouched(); return; } const formValue = this.listForm.getRawValue(); const payload = { name: formValue.name.trim(), description: formValue.description.trim() || undefined, }; const saveRequest = this.isCreateMode() || !listId ? this.listsService.createList(payload) : this.listsService.updateList(listId, payload); this.saving.set(true); saveRequest.pipe(finalize(() => this.saving.set(false))).subscribe({ next: (list) => { this.setList(list); if (this.isCreateMode()) { this.isCreateMode.set(false); this.editing.set(false); void this.router.navigate(['/lists', list.id], { replaceUrl: true }); } else { this.editing.set(false); } this.snackBar.open('Liste gespeichert.', 'OK', { duration: 2500 }); }, error: (error: unknown) => { this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); }, }); } protected addItem(): void { const listId = this.listId(); if (!listId || !this.canEditItems() || this.itemForm.invalid) { this.itemForm.markAllAsTouched(); return; } const formValue = this.itemForm.getRawValue(); this.addingItem.set(true); this.listsService .addItem(listId, { title: formValue.title.trim(), required: formValue.required, }) .pipe(finalize(() => this.addingItem.set(false))) .subscribe({ next: (list) => { this.setList(list); this.itemForm.reset({ title: '', required: true }); }, error: (error: unknown) => { this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); }, }); } protected loadSuggestions(): void { const listId = this.listId(); if (!listId || !this.canEditItems() || this.loadingSuggestions()) { return; } this.loadingSuggestions.set(true); this.suggestionsLoaded.set(false); this.listsService .suggestItems(listId) .pipe(finalize(() => this.loadingSuggestions.set(false))) .subscribe({ next: (response) => { this.itemSuggestions.set(response.suggestions); this.suggestionsLoaded.set(true); }, error: (error: unknown) => { this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); }, }); } protected addSuggestion(suggestion: ListItemSuggestion): void { const listId = this.listId(); if (!listId || this.addingSuggestionTitle()) { return; } this.addingSuggestionTitle.set(suggestion.title); this.listsService .addItem(listId, { title: suggestion.title, notes: suggestion.notes, quantity: suggestion.quantity, required: suggestion.required, }) .pipe(finalize(() => this.addingSuggestionTitle.set(null))) .subscribe({ next: (list) => { this.setList(list); this.itemSuggestions.update((suggestions) => suggestions.filter( (itemSuggestion) => this.suggestionKey(itemSuggestion.title) !== this.suggestionKey(suggestion.title), ), ); }, error: (error: unknown) => { this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); }, }); } protected toggleItem(item: UserListItem, checked: boolean): void { const listId = this.listId(); const currentList = this.list(); if (!listId || !currentList || this.updatingItemId()) { return; } this.updatingItemId.set(item.id); this.list.set({ ...currentList, items: currentList.items.map((existingItem) => existingItem.id === item.id ? { ...existingItem, checked } : existingItem, ), }); this.listsService .updateItem(listId, item.id, { checked }) .pipe(finalize(() => this.updatingItemId.set(null))) .subscribe({ next: (list) => { this.setList(list); }, error: (error: unknown) => { this.list.set(currentList); this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); }, }); } protected startEditing(): void { this.editing.set(true); } protected cancelEditing(): void { const list = this.list(); if (this.isCreateMode()) { void this.router.navigateByUrl('/lists'); return; } if (list) { this.listForm.reset({ name: list.name, description: list.description ?? '', }); } this.itemForm.reset({ title: '', required: true }); this.editing.set(false); } protected checkedCount(list: UserList): number { return list.items.filter((item) => item.checked).length; } protected visibleItems(list: UserList): UserListItem[] { return this.uncheckedFirst(list.items); } protected searchShareUsers(term: string): void { this.shareSearchTerm.set(term); if (term.trim().length < 2) { this.shareSearchResults.set([]); return; } this.searchingUsers.set(true); this.authService .searchUsers(term) .pipe(finalize(() => this.searchingUsers.set(false))) .subscribe({ next: (users) => this.shareSearchResults.set(users), error: (error: unknown) => { this.shareSearchResults.set([]); this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); }, }); } protected shareWithUser(user: PublicUserSearchResult): void { const listId = this.listId(); if (!listId || this.sharingUserId()) { return; } this.sharingUserId.set(user.id); this.listsService .shareList(listId, user.id) .pipe(finalize(() => this.sharingUserId.set(null))) .subscribe({ next: (list) => { this.setList(list, !this.showEditor()); this.shareSearchTerm.set(''); this.shareSearchResults.set([]); this.snackBar.open('Liste geteilt.', 'OK', { duration: 2500 }); }, error: (error: unknown) => { this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); }, }); } protected removeCollaborator(userId: string): void { const listId = this.listId(); if (!listId || this.removingShareUserId()) { return; } this.removingShareUserId.set(userId); this.listsService .removeShare(listId, userId) .pipe(finalize(() => this.removingShareUserId.set(null))) .subscribe({ next: (list) => { this.setList(list, !this.showEditor()); this.snackBar.open('Freigabe entfernt.', 'OK', { duration: 2500 }); }, error: (error: unknown) => { this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); }, }); } protected displayUser(user: { name?: string; email: string }): string { return user.name ? `${user.name} (${user.email})` : user.email; } protected async backToLists(): Promise { await this.router.navigateByUrl('/lists'); } private subscribeToRealtime(listId: string): void { this.listsRealtimeService .events() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: (event) => this.applyRealtimeEvent(listId, event), }); } private applyRealtimeEvent(listId: string, event: ListRealtimeEvent): void { if (event.type === 'list.snapshot' && event.data.id === listId) { this.errorMessage.set(null); this.loading.set(false); // Remote snapshots should update visible item state immediately, but they // must not overwrite a title/description form while the user is editing it. this.setList(event.data, !this.showEditor()); return; } if (event.type === 'list.deleted' && event.data.listId === listId) { this.list.set(null); this.loading.set(false); this.editing.set(false); this.errorMessage.set('Diese Liste wurde geloescht.'); } } private setList(list: UserList, resetForm = true): void { this.list.set(list); if (resetForm) { this.listForm.reset({ name: list.name, description: list.description ?? '', }); } } private listId(): string | null { return this.route.snapshot.paramMap.get('listId'); } private uncheckedFirst(items: UserListItem[]): UserListItem[] { return items .map((item, index) => ({ item, index })) .sort((a, b) => { if (a.item.checked !== b.item.checked) { return a.item.checked ? 1 : -1; } return a.index - b.index; }) .map(({ item }) => item); } private suggestionKey(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase(); } }