diff --git a/firebase.json b/firebase.json index 9e26dfe..6d2acd0 100644 --- a/firebase.json +++ b/firebase.json @@ -1 +1,5 @@ -{} \ No newline at end of file +{ + "hosting": { + "public": "dist/firebase-ai-angular/browser" + } +} diff --git a/src/app/app.component.html b/src/app/app.component.html index f521fb0..081e6da 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,275 +1,144 @@ - - -
-
- -
-
- check -
- -
- - -
- -
-
- @for (task of this.tasks; track task.maintask.id) { -
-
-
-
- -

- {{ task.maintask.title }} -

-
-
- {{ task.maintask.priority | titlecase }} -
-
-
- -
    - @for (subtask of task.subtasks; track subtask.id) { -
  • - -

    - {{ subtask.title }} -

    -
  • - } -
-
- } -
-
+
+
+ +
+ Icon +

+ Planning with the Gemini API +

-
-
- - -
- @if (isLoading()) { +
-
- -

- Generating suggested task... -

-
-
- } -
-
- - Title - - -
-
- - - High - Medium - Low - None - -
-
- @if(imagePreview()) { -
-

From image

-

- Subtasks added from the following image -

- Task Image -
- } - -
-

Subtasks

-
- - -
-
-
+ + +
+ +
+ + Add a prompt + + + Update the prompt to generate a new list, like: + @if (this.formControls.locationSelected.value) { + Help me plan a trip to this location + } @else { + Provide a list of tasks to clean this room + } + + -
- -
+ +
-
    - @for (subtask of this.subtasks; track subtask.task.id) { -
  • -

    {{ subtask.task.title }}

    - - - - - - -
  • - } -
+ +
+ @if (isLoading()) { + + } @else if (generatedTask) { + + + }
- +
+
+
+ + +
+
+
+ + + @if (this.tasks.length > 0) { +
+
+ @for (task of this.tasks; track task.maintask.id) { + + + } +
+ + Generated by the Gemini API +
+ }
diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e70fec9..229a433 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -14,3 +14,18 @@ * limitations under the License. */ +.mat-mdc-outlined-button { + --mdc-outlined-button-label-text-color: #65558f; + height: 48px; + border-radius: 0.75rem +} + +.mat-mdc-button { + background-color: #bbdefb; + height: 48px; + border-radius: 0.75rem +} + +::ng-deep .mdc-notched-outline__notch { + border-right: none; +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 35da968..e1abf4f 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -40,6 +40,8 @@ describe('AppComponent', () => { const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, firebase-ai-angular'); + expect(compiled.querySelector('h1')?.textContent).toContain( + 'Hello, firebase-ai-angular' + ); }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index df9e47c..db24618 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -14,18 +14,16 @@ * limitations under the License. */ -import { Component, ElementRef, signal, ViewChild } from '@angular/core'; +import { ViewChild, ChangeDetectorRef, Component, signal } from '@angular/core'; import { Timestamp } from '@angular/fire/firestore'; import { CommonModule, AsyncPipe } from '@angular/common'; import { - FormBuilder, - FormGroup, + FormControl, FormsModule, ReactiveFormsModule, Validators, } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatChipsModule } from '@angular/material/chips'; import { MatNativeDateModule } from '@angular/material/core'; @@ -35,11 +33,17 @@ import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { TaskWithSubtasks, Task, TaskService } from './services/task.service'; -import { catchError, take, tap } from 'rxjs/operators'; -import { Observable } from 'rxjs'; -import { GoogleGenerativeAIFetchError } from '@google/generative-ai'; +import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; +import { Observable, forkJoin, from, EMPTY } from 'rxjs'; +import { TaskComponent } from './task.component'; +import { CheckboximageComponent } from './checkboximage.component'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { ChangeDetectionStrategy } from '@angular/core'; + +const HELP_ME_CLEAN = 'Clean this room'; +const HELP_ME_PLAN = 'Plan a trip to Greece'; @Component({ selector: 'app-root', @@ -48,433 +52,184 @@ import { GoogleGenerativeAIFetchError } from '@google/generative-ai'; CommonModule, FormsModule, ReactiveFormsModule, - MatSnackBarModule, + MatSnackBarModule, // Do not remove: used by service. MatButtonModule, MatMenuModule, MatChipsModule, MatFormFieldModule, MatSlideToggleModule, + MatProgressSpinnerModule, MatIconModule, MatInputModule, MatNativeDateModule, - MatButtonToggleModule, MatSelectModule, MatCheckboxModule, AsyncPipe, + TaskComponent, + CheckboximageComponent, ], templateUrl: './app.component.html', styleUrl: './app.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { - taskForm!: FormGroup; - selectedTaskId: string | null = null; - tasks: any[] = []; - subtasks: { task: Task; editing: boolean }[] = []; - newSubtaskTitle = ''; - imagePreview = signal(''); + readonly formControls = { + locationSelected: new FormControl(true), + prompt: new FormControl(HELP_ME_PLAN, { + nonNullable: true, + validators: Validators.required, + }), + }; isLoading = signal(false); - @ViewChild('fileInput') fileInput: ElementRef | undefined; + tasks: TaskWithSubtasks[] = []; + generatedTask?: TaskWithSubtasks; + + @ViewChild('location') locationImage! : CheckboximageComponent; + @ViewChild('room') roomImage! : CheckboximageComponent; + + locationFile?: File; + roomFile?: File; constructor( public taskService: TaskService, - private fb: FormBuilder, - private snackBar: MatSnackBar - ) { } + private cdr: ChangeDetectorRef, + ) {} ngOnInit(): void { - this.initForm(); - this.loadTasks().subscribe((tasks) => { - if (tasks.length === 0) { - this.generateMaintask(); - } - }); + this.loadTasks().subscribe(); } - initForm(): void { - this.taskForm = this.fb.group({ - title: ['', Validators.required], - priority: ['none', Validators.required], - completed: [false], - }); + async ngAfterViewInit() { + this.locationFile = await this.locationImage.getFile(); + this.roomFile = await this.roomImage.getFile(); } - openEditor(task: Task | null = null): void { - if (task) { - this.selectedTaskId = task.id; - this.taskForm.patchValue({ - ...task, - }); - - this.loadSubtasks(task.id); - } else { - this.selectedTaskId = null; - } + async onGoClick() { + await this.generateMaintask(); } - submit(): void { - if (this.taskForm.invalid) { - this.handleError('Form invalid', 'Please check all fields'); - return; - } - - const newTaskRef = this.selectedTaskId - ? this.taskService.createTaskRef(this.selectedTaskId) - : this.taskService.createTaskRef(); // Generate Firestore ID only if new - - const maintaskInput: Task = { - ...this.taskForm.value, - id: this.selectedTaskId || newTaskRef.id, - owner: this.taskService.currentUser?.uid || this.taskService.localUid!, - createdTime: Timestamp.fromDate(new Date()), - }; - - const subtaskInput = this.subtasks.map((subtask, index) => ({ - ...subtask.task, - parentId: this.selectedTaskId || newTaskRef.id, - order: index, - })); - - const existingTaskIndex = this.tasks.findIndex( - (t) => t.maintask.id === maintaskInput.id - ); + onSelectLocation(unused: boolean) { + this.formControls.prompt.setValue(HELP_ME_PLAN); + this.formControls.locationSelected.setValue(true); + } - if (existingTaskIndex !== -1) { - this.tasks[existingTaskIndex] = { - maintask: maintaskInput, - subtasks: subtaskInput, - }; - } else { - this.tasks.push({ maintask: maintaskInput, subtasks: subtaskInput }); - } + onSelectRoom(unused: boolean) { + this.formControls.prompt.setValue(HELP_ME_CLEAN); + this.formControls.locationSelected.setValue(false); + } - this.taskService.tasksSubject.next([...this.tasks]); + onResetClick() { + this.generatedTask = undefined; + this.formControls.prompt.setValue(this.getCurrentPromptPlaceHolder()); + } - if (this.selectedTaskId) { - this.taskService.updateMaintaskAndSubtasks(maintaskInput, subtaskInput); + getCurrentPromptPlaceHolder() { + if (this.formControls.locationSelected.value) { + return HELP_ME_PLAN; } else { - this.taskService.addMaintaskWithSubtasks(maintaskInput, subtaskInput); + return HELP_ME_CLEAN; } - - this.resetForm(); - } - - private resetForm(): void { - this.selectedTaskId = null; - this.subtasks = []; - this.taskForm.reset({ - title: '', - priority: 'none', - completed: false, - }); - this.imagePreview.set(''); - this.isLoading.set(false); } - handleError(error: any, userMessage: string, duration: number = 3000): void { - console.error('Error:', error); - this.snackBar.open(userMessage, 'Close', { - duration, - }); + handleError(error: any, userMessage?: string, duration: number = 3000): void { + this.taskService.handleError(error, userMessage, duration); } - loadTasks(): Observable { + loadTasks(): Observable { return this.taskService.tasks$.pipe( - tap((tasks: Task[]) => { - const taskMap = new Map(); - tasks.forEach((task: Task) => { - if (!task.parentId) { - // It's a main task - if (taskMap.has(task.id)) { - taskMap.get(task.id)!.maintask = task; - } else { - taskMap.set(task.id, { maintask: task, subtasks: [] }); - } - } else { - // It's a subtask - if (taskMap.has(task.parentId)) { - taskMap.get(task.parentId)!.subtasks.push(task); - } else { - taskMap.set(task.parentId, { - maintask: {} as Task, - subtasks: [task], - }); + switchMap((tasks: Task[]) => + { + if (tasks.length === 0) { + return from(this.generateMaintask()).pipe(map((x) => [])); + } + return forkJoin( + tasks.map((task: Task) => { + if (!task.parentId) { + return this.taskService.loadSubtasks(task.id).pipe( + take(1), + map((subtasks: Task[]) => { + return { maintask: task, subtasks }; + }) + ); } - } - }); - - this.tasks = Array.from(taskMap.values()); + // tasks$ should only return main tasks, however we recevied one that is not. + return EMPTY; + }) + ); + }), + tap((tasks: TaskWithSubtasks[]) => { + this.tasks = tasks; + this.cdr.markForCheck(); }), catchError((error: any) => { - console.error('Error loading tasks:', error); - this.snackBar.open('Error loading data', 'Close', { - duration: 3000, - }); + this.handleError(error, 'Error loading tasks'); return []; - }), - ); - } - - loadSubtasks(maintaskId: string): void { - this.taskService - .loadSubtasks(maintaskId) - .then((subtasksObservable: any) => { - subtasksObservable.pipe(take(1)).subscribe({ - next: (subtasks: any) => { - this.subtasks = subtasks.map((task: any) => ({ - task, - editing: false, - })); - }, - error: (error: any) => { - if (error.message.indexOf('Missing or insufficient permissions') >= 0) { - this.handleError(error, 'Check Firestore permissions in Firebase Console at: https://console.firebase.google.com/project/_/firestore/databases/-default-/rules', 10000); - } else { - this.handleError(error, 'Failed to load subtasks.'); - } - }, - }); }) - .catch((error: any) => { - console.error('Error resolving subtasks observable:', error); - this.snackBar.open('Error resolving subtasks observable', 'Close', { - duration: 3000, - }); - }); + ); } - async handleFileInput(event: any): Promise { - const file = event.target.files[0] as File | null; - if (!file) { - this.handleError(null, 'File not found'); - return; - } - - const fileSizeMB = file.size / (1024 * 1024); // Convert bytes to MB - if (fileSizeMB > 20) { - this.handleError( - null, - 'File size exceeds 20MB limit. Please select a smaller file.' - ); - return; - } + async generateMaintask(): Promise { this.isLoading.set(true); try { - await this.displayImagePreview(file); - const existingSubtasks = this.subtasks.map(t => t.task.title); - const title = this.taskForm.get('title')?.value || ''; + const title = this.formControls.prompt.value; + const file = this.formControls.locationSelected ? this.locationFile : this.roomFile; const generatedSubtasks = await this.taskService.generateSubtasks({ file, - title, - existingSubtasks, - }); - this.addSubtasksToList(generatedSubtasks.subtasks); - } catch (error) { - if (error instanceof GoogleGenerativeAIFetchError) { - this.handleError(error, error.message); - } else { - this.handleError(error, 'Failed to generate subtasks from image.'); - } - } finally { - this.isLoading.set(false); - } - } - - async handleTitleInput(): Promise { - const title = this.taskForm.get('title')?.value; - - if (!title) { - this.handleError( - 'Empty title', - 'Please enter a title for the main task first.' - ); - return; - } - this.isLoading.set(true); - const existingSubtasks = this.subtasks.map(t => t.task.title); - try { - const generatedSubtasks = await this.taskService.generateSubtasks({ - title, - existingSubtasks + title: `Provide a list of tasks to ${title}`, + existingSubtasks: [], }); - this.addSubtasksToList(generatedSubtasks.subtasks); - } catch (error) { - if (error instanceof GoogleGenerativeAIFetchError) { - this.handleError(error, error.message); - } else { - this.handleError(error, 'Failed to generate subtasks from title.'); - } - } finally { - this.isLoading.set(false); - } - } - - private addSubtasksToList(subtasks: any[]): void { - const owner = - this.taskService.currentUser?.uid || this.taskService.localUid!; - const currentTime = Timestamp.fromDate(new Date()); - - const newSubtasks = subtasks.map((subtask: any, index: number) => ({ - task: { - id: this.taskService.createTaskRef().id, - title: subtask.title, - completed: false, - parentId: this.selectedTaskId || '', // Placeholder, to be set on save - order: this.subtasks.length + index, - owner: owner, - createdTime: currentTime, - }, - editing: false, - })); - - this.subtasks = this.subtasks.concat(newSubtasks); - } - - async generateMaintask(): Promise { - this.isLoading.set(true); - try { - const generatedTask = await this.taskService.generateMaintask(); const newTaskRef = this.taskService.createTaskRef(); - const newTask: Task = { + const maintask: Task = { id: newTaskRef.id, - title: generatedTask.title, + title: title, completed: false, owner: this.taskService.currentUser?.uid || this.taskService.localUid!, createdTime: Timestamp.fromDate(new Date()), - priority: generatedTask.priority - ? generatedTask.priority.toLowerCase() - : 'none', + priority: 'none', }; - - this.openEditor(newTask); - } catch (error) { - if (error instanceof GoogleGenerativeAIFetchError) { - if (error.message.indexOf('API key not valid') > 0) { - this.handleError(error, 'Error loading Gemini API key. Please rerun Terraform with `terraform apply --auto-approve`', 10000); - } else { - this.handleError(error, error.message); + const subtasks = generatedSubtasks.subtasks?.map( + (generatedSubtask: { order: number; title: string }) => { + return { + id: this.taskService.createTaskRef().id, + title: generatedSubtask.title, + completed: false, + parentId: newTaskRef.id, + order: generatedSubtask.order, + owner: maintask.owner, + createdTime: maintask.createdTime, + }; } - } else { - this.handleError(error, 'Failed to generate main task.'); - } + ); + this.generatedTask = { maintask, subtasks }; + } catch (error) { + this.handleError(error, 'Failed to generate main task.'); } finally { this.isLoading.set(false); } } - completeTask(task: Task): void { - const updated = { ...task, completed: !task.completed }; - - if (!task.parentId) { - const maintaskIndex = this.tasks.findIndex( - (t) => t.maintask.id === task.id - ); - if (maintaskIndex !== -1) { - this.tasks[maintaskIndex].maintask = updated; - } - } else { - const subtaskIndex = this.subtasks.findIndex( - (st) => st.task.id === task.id + onSave(): void { + if (this.generatedTask) { + this.taskService.addMaintaskWithSubtasks( + this.generatedTask.maintask, + this.generatedTask.subtasks ); - if (subtaskIndex !== -1) { - this.subtasks[subtaskIndex].task = updated; - } + this.tasks.push(this.generatedTask); + this.generatedTask = undefined; } - - this.taskService - .updateTask(updated, updated.id) - .then(() => { - console.log('Task completion status updated in Firestore'); - }) - .catch((error: any) => { - console.error( - 'Error updating task completion status in Firestore', - error - ); - this.snackBar.open('Error updating task', 'Close', { - duration: 3000, - }); - }); } - addSubtask(): void { - if (!this.newSubtaskTitle.trim()) { - this.handleError('Empty title', 'Please populate title'); - return; + deleteCurrentMainAndSubTasks(task: TaskWithSubtasks): void { + const index = this.tasks.indexOf(task, 0); + if (index > -1) { + this.tasks.splice(index, 1); } - - const newSubtask: Task = { - id: this.taskService.createTaskRef().id, - title: this.newSubtaskTitle.trim(), - completed: false, - parentId: this.selectedTaskId || '', - order: this.subtasks.length, - owner: this.taskService.currentUser?.uid || this.taskService.localUid!, - createdTime: Timestamp.fromDate(new Date()), - }; - - this.subtasks.push({ task: newSubtask, editing: false }); - this.newSubtaskTitle = ''; + this.taskService.deleteMaintaskAndSubtasks(task.maintask.id); } - moveSubtaskOrder( - subtask: { task: Task; editing: boolean }, - direction: 'up' | 'down' - ): void { - const index = this.subtasks.findIndex( - (st) => st.task.id === subtask.task.id - ); - - if (direction === 'up' && index > 0) { - [this.subtasks[index], this.subtasks[index - 1]] = [ - this.subtasks[index - 1], - this.subtasks[index], - ]; - } else if (direction === 'down' && index < this.subtasks.length - 1) { - [this.subtasks[index], this.subtasks[index + 1]] = [ - this.subtasks[index + 1], - this.subtasks[index], - ]; - } - - this.subtasks.forEach((st, i) => { - st.task.order = i; + async onTasksCompleted(tasks: Task[]) { + tasks.forEach((task: Task) => { + this.taskService.updateTask(task); }); } - - deleteCurrentMainAndSubTasks(): void { - if (this.selectedTaskId) { - this.taskService.deleteMaintaskAndSubtasks(this.selectedTaskId); - this.resetForm(); - } - } - - deleteSubtask(subtask: { task: Task; editing: boolean }): void { - this.subtasks = this.subtasks.filter( - (st) => st.task.id !== subtask.task.id - ); - } - - async onFileDrop(event: DragEvent): Promise { - event.preventDefault(); - const file = event.dataTransfer?.files[0] as File | null; - - if (file) { - const inputEvent = { target: { files: [file] } } as any; - await this.handleFileInput(inputEvent); - } else { - this.handleError(null, 'No file detected in the drop event.'); - } - } - - onDragOver(event: DragEvent): void { - event.preventDefault(); - } - - private async displayImagePreview(file: File): Promise { - const reader = new FileReader(); - reader.onload = () => { - this.imagePreview.set(reader.result as string); - }; - reader.readAsDataURL(file); - } } diff --git a/src/app/checkboximage.component.html b/src/app/checkboximage.component.html new file mode 100644 index 0000000..f4f7c50 --- /dev/null +++ b/src/app/checkboximage.component.html @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/src/app/checkboximage.component.scss b/src/app/checkboximage.component.scss new file mode 100644 index 0000000..55c4ee7 --- /dev/null +++ b/src/app/checkboximage.component.scss @@ -0,0 +1,13 @@ +.checkboximage-card { + border-radius: 24px; + max-width: 400px; + img { + border-radius: 24px; + } +} + +.checkboximage-button { + position: absolute; + top: 20px; + left: 20px; +} diff --git a/src/app/checkboximage.component.spec.ts b/src/app/checkboximage.component.spec.ts new file mode 100644 index 0000000..dd813c4 --- /dev/null +++ b/src/app/checkboximage.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CheckboximageComponent } from './checkboximage.component'; + +describe('CheckboximageComponent', () => { + let component: CheckboximageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CheckboximageComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CheckboximageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/checkboximage.component.ts b/src/app/checkboximage.component.ts new file mode 100644 index 0000000..e488b0b --- /dev/null +++ b/src/app/checkboximage.component.ts @@ -0,0 +1,34 @@ +import { + EventEmitter, + input, + output, + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; + +@Component({ + selector: 'app-checkboximage', + standalone: true, + imports: [MatCheckbox, MatIconModule, MatCardModule], + templateUrl: './checkboximage.component.html', + styleUrl: './checkboximage.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CheckboximageComponent { + checked = input(false); + src = input(''); + onCheckedChanged = output(); + + onClick() { + this.onCheckedChanged.emit(!this.checked); + } + + async getFile() { + const fetchImage = await fetch(this.src()); + const blob = await fetchImage.blob(); + return new File([blob], 'dot.png', blob); + } +} diff --git a/src/app/roundbutton.component.html b/src/app/roundbutton.component.html new file mode 100644 index 0000000..9f491d6 --- /dev/null +++ b/src/app/roundbutton.component.html @@ -0,0 +1,10 @@ +
+ +
\ No newline at end of file diff --git a/src/app/roundbutton.component.scss b/src/app/roundbutton.component.scss new file mode 100644 index 0000000..c488c39 --- /dev/null +++ b/src/app/roundbutton.component.scss @@ -0,0 +1,13 @@ +::ng-deep .mat-mdc-button { + height: 48px; + --mdc-text-button-label-text-color: black; +} + +::ng-deep .subtask { + --mdc-text-button-label-text-weight: 400; + --mdc-text-button-label-text-color: #333; +} + +.maintask { + margin-bottom: 8px; +} diff --git a/src/app/roundbutton.component.spec.ts b/src/app/roundbutton.component.spec.ts new file mode 100644 index 0000000..b834ba1 --- /dev/null +++ b/src/app/roundbutton.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RoundbuttonComponent } from './roundbutton.component'; + +describe('RoundbuttonComponent', () => { + let component: RoundbuttonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RoundbuttonComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RoundbuttonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/roundbutton.component.ts b/src/app/roundbutton.component.ts new file mode 100644 index 0000000..a65427a --- /dev/null +++ b/src/app/roundbutton.component.ts @@ -0,0 +1,29 @@ +import { + EventEmitter, + input, + output, + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'app-roundbutton', + standalone: true, + imports: [MatButtonModule, MatIconModule], + templateUrl: './roundbutton.component.html', + styleUrl: './roundbutton.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RoundbuttonComponent { + checked = input(false); + title = input(''); + subtask = input(false); + disabled = input(false); + onCheckedChanged = output(); + + onClick() { + this.onCheckedChanged.emit(!this.checked); + } +} diff --git a/src/app/services/task.service.ts b/src/app/services/task.service.ts index 740a4dd..44eb0ed 100644 --- a/src/app/services/task.service.ts +++ b/src/app/services/task.service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { inject, Injectable } from '@angular/core'; +import { NgZone, inject, Injectable } from '@angular/core'; import { Auth, authState, @@ -23,8 +23,10 @@ import { User, } from '@angular/fire/auth'; import { getApp } from '@angular/fire/app'; +import { MatSnackBar } from '@angular/material/snack-bar'; -import { Observable, BehaviorSubject, of, firstValueFrom } from 'rxjs'; +import { switchMap, tap, take, catchError } from 'rxjs/operators'; +import { Observable, of, Subject } from 'rxjs'; import { doc, Firestore, @@ -32,11 +34,13 @@ import { collection, deleteDoc, collectionData, + collectionCount, query, orderBy, Timestamp, where, } from '@angular/fire/firestore'; +import { GoogleGenerativeAIFetchError } from '@google/generative-ai'; import { v4 as uuidv4 } from 'uuid'; import { GoogleGenerativeAI } from '@google/generative-ai'; import { environment } from '../../environments/environments'; @@ -63,7 +67,7 @@ export type TaskWithSubtasks = { const MODEL_CONFIG = { model: 'gemini-1.5-flash', generationConfig: { responseMimeType: 'application/json' }, - systemInstruction: "Keep TODO titles short, ideally within 7 words" + systemInstruction: 'Keep TODO titles short, ideally within 7 words', }; @Injectable({ @@ -82,12 +86,12 @@ export class TaskService { private experimentModel = this.genAI.getGenerativeModel(MODEL_CONFIG); user$ = authState(this.auth); - public tasksSubject = new BehaviorSubject([]); + public tasksSubject = new Subject(); tasks$ = this.tasksSubject.asObservable(); // Observable for components to subscribe to currentUser: User | null = null; public localUid: string | null = null; - constructor() { + constructor(private snackBar: MatSnackBar, private zone: NgZone) { this.user$.subscribe((user: User | null) => { this.currentUser = user; if (user) { @@ -122,6 +126,35 @@ export class TaskService { .catch((error) => console.error('Sign out error:', error)); } + handleError(error: any, userMessage?: string, duration: number = 3000): void { + if (error instanceof GoogleGenerativeAIFetchError) { + if (error.message.indexOf('API key not valid') > 0) { + userMessage = 'Error loading Gemini API key. Please rerun Terraform with `terraform apply --auto-approve`'; + } else { + userMessage = error.message; + } + duration = 10000; + } + if (error.message.indexOf('Missing or insufficient permissions') >= 0) { + userMessage = + 'Error communicating with Firestore. Please rerun Terraform with `terraform apply --auto-approve`'; + duration = 10000; + } + if (error.message.indexOf('The query requires an index') >= 0) { + // It happens when there are non zero number of tasks. + return; + } + + console.error('Error:', error); + this.zone.run(() => { + this.snackBar.open(userMessage || error.message, 'Close', { + duration, + verticalPosition: 'top', + horizontalPosition: 'center', + }); + }); + } + private generateLocalUid(): string { return 'local-' + uuidv4(); } @@ -129,29 +162,41 @@ export class TaskService { loadTasks(): Observable { const taskQuery = query( collection(this.firestore, 'todos'), + where('priority', '!=', 'null'), orderBy('createdTime', 'desc') ); - - return collectionData(taskQuery, { idField: 'id' }) as Observable; + return this.loadTaskCount().pipe( + take(1), + switchMap((taskCount) => { + if (taskCount === 0) { + return of([] as Task[]); + } + return collectionData(taskQuery, { idField: 'id' }) as Observable< + Task[] + >; + }), + catchError((error: Error) => { + this.handleError(error); + return []; + }) + ); } - async loadSubtasks(maintaskId: string): Promise> { - const subtaskQuery = query( + loadTaskCount(): Observable { + const taskQuery = query( collection(this.firestore, 'todos'), - where('parentId', '==', maintaskId) + where('priority', '!=', 'null') ); - return await collectionData(subtaskQuery, { idField: 'id' }); + return collectionCount(taskQuery, { idField: 'id' }); } - private refreshTasks(): void { - this.loadTasks().subscribe({ - next: (tasks) => { - this.tasksSubject.next(tasks); - }, - error: (error) => { - console.error('Error fetching tasks:', error); - }, - }); + loadSubtasks(maintaskId: string): Observable { + const subtaskQuery = query( + collection(this.firestore, 'todos'), + where('parentId', '==', maintaskId), + orderBy('order', 'asc') + ); + return collectionData(subtaskQuery, { idField: 'id' }); } createTaskRef(id?: string) { @@ -173,27 +218,12 @@ export class TaskService { } as any; } - async generateMaintask(): Promise { - const activeTasks = this.tasksSubject - .getValue() - .filter((task) => !task.completed && !task.parentId); - const prompt = `Generate a TODO task that ${ - activeTasks.length > 0 - ? `is different from any of ${JSON.stringify(activeTasks[0].title)}.` - : `should be feasible in a few days at this time of the year` - } using this JSON schema: ${JSON.stringify({ - type: 'object', - properties: { - title: { type: 'string' }, - description: { type: 'string' }, - priority: { type: 'string' }, - }, - })}`; + async updateTask(maintask: Task): Promise { try { - const result = await this.experimentModel.generateContent(prompt); - return JSON.parse(result.response.text()); + const maintaskRef = doc(this.firestore, 'todos', maintask.id); + await setDoc(maintaskRef, maintask, { merge: true }); } catch (error) { - console.error('Failed to generate task', error); + this.handleError(error, 'Error updating task'); throw error; } } @@ -201,7 +231,7 @@ export class TaskService { async generateSubtasks(input: { file?: File; title: string; - existingSubtasks: string[] + existingSubtasks: string[]; }): Promise { const { file, title } = input; @@ -216,7 +246,9 @@ export class TaskService { title ? `main task "${title}" ` : '' } ${ file ? 'also consider the image in the input.' : '' - } excluding these existing subtasks ${input.existingSubtasks.join("\n")}. The output should be in the format: + } excluding these existing subtasks ${input.existingSubtasks.join( + '\n' + )}. The output should be in the format: ${JSON.stringify({ subtasks: [ { @@ -233,7 +265,7 @@ export class TaskService { const response = await result.response.text(); return JSON.parse(response); } catch (error) { - console.error('Failed to generate subtasks', error); + this.handleError(error, 'Failed to generate subtasks'); throw error; } } @@ -267,91 +299,36 @@ export class TaskService { }; await setDoc(subtaskRef, newSubtask); } - - this.refreshTasks(); } catch (error) { - console.error('Error adding main task and subtasks to Firestore', error); - } - } - - async updateMaintaskAndSubtasks( - maintask: Task, - subtasks: Task[] - ): Promise { - try { - const maintaskRef = doc(this.firestore, 'todos', maintask.id); - await setDoc(maintaskRef, maintask, { merge: true }); - - const subtasksObservable = await this.loadSubtasks(maintask.id); - const existingSubtasks = await firstValueFrom(subtasksObservable); - - const currentSubtaskIds = new Set(subtasks.map((subtask) => subtask.id)); - - await Promise.all( - existingSubtasks.map(async (existingSubtask) => { - if (!currentSubtaskIds.has(existingSubtask.id)) { - const subtaskRef = doc(this.firestore, 'todos', existingSubtask.id); - await deleteDoc(subtaskRef); - } - }) - ); - - await Promise.all( - subtasks.map(async (subtask) => { - const subtaskRef = doc(this.firestore, 'todos', subtask.id); - await setDoc(subtaskRef, subtask, { merge: true }); - }) + this.handleError( + error, + 'Error adding main task and subtasks to Firestore' ); - - this.refreshTasks(); - } catch (error) { - console.error('Error updating/deleting tasks and subtasks', error); - throw error; } } async deleteMaintaskAndSubtasks(maintaskId: string): Promise { try { - const subtasksObservable = await this.loadSubtasks(maintaskId); - - subtasksObservable.subscribe(async (subtasks) => { - for (let subtask of subtasks) { - const subtaskRef = doc(this.firestore, 'todos', subtask.id); - await deleteDoc(subtaskRef); - } - - const maintaskRef = doc(this.firestore, 'todos', maintaskId); - await deleteDoc(maintaskRef); - - this.refreshTasks(); - }); - } catch (error) { - console.error( - 'Error deleting main task and subtasks from Firestore', - error - ); - } - } - - async updateTask(taskData: Task, id: string): Promise { - const userId = - this.currentUser?.uid || this.localUid || this.generateLocalUid(); - - try { - const task = { ...taskData, userId: userId }; - await setDoc(doc(this.firestore, 'todos', id), task); - this.refreshTasks(); - } catch (error) { - console.error('Error updating task in Firestore', error); - } - } + const subtasksObservable = this.loadSubtasks(maintaskId); + + subtasksObservable + .pipe( + catchError((error: Error) => { + this.handleError(error); + return of([]); + }) + ) + .subscribe(async (subtasks) => { + for (let subtask of subtasks) { + const subtaskRef = doc(this.firestore, 'todos', subtask.id); + await deleteDoc(subtaskRef); + } - async deleteTask(id: string): Promise { - try { - await deleteDoc(doc(this.firestore, 'todos', id)); - this.refreshTasks(); + const maintaskRef = doc(this.firestore, 'todos', maintaskId); + await deleteDoc(maintaskRef); + }); } catch (error) { - console.error('Error deleting task from Firestore', error); + this.handleError(error); } } } diff --git a/src/app/task.component.html b/src/app/task.component.html new file mode 100644 index 0000000..135b63a --- /dev/null +++ b/src/app/task.component.html @@ -0,0 +1,24 @@ + + +
+ + @if (canDelete()) { + + } +
+ @if (task()?.subtasks || false) { + + } + @for (subtask of task()?.subtasks; track subtask.id) { + + } + @if ((showGeneratedWithGemini())) { +
Generated by the Gemini API
+ } +
+
diff --git a/src/app/task.component.scss b/src/app/task.component.scss new file mode 100644 index 0000000..aba40ee --- /dev/null +++ b/src/app/task.component.scss @@ -0,0 +1,30 @@ +.task-card { + border-radius: 24px; + margin: 6px; +} + +::ng-deep .mat-mdc-card-outlined { + --mdc-outlined-card-container-color: white; +} + +.mat-mdc-card-outlined { + --mdc-outlined-card-container-color: white; +} + +.mat-mdc-icon-button { + --mdc-icon-button-icon-size: 48px; + height: 48px !important; + width: 48px !important; +} + +.deletebutton { + margin-bottom: 8px; +} + +.generated-gemini { + font: 12px; + font-family: "Google Sans", "Helvetica Neue"; + font-weight: 400; + line-height: 20px; + color: #1e88e5; +} diff --git a/src/app/task.component.spec.ts b/src/app/task.component.spec.ts new file mode 100644 index 0000000..a5c3d70 --- /dev/null +++ b/src/app/task.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TaskComponent } from './task.component'; + +describe('TaskComponent', () => { + let component: TaskComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TaskComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/task.component.ts b/src/app/task.component.ts new file mode 100644 index 0000000..a94f677 --- /dev/null +++ b/src/app/task.component.ts @@ -0,0 +1,71 @@ +import { + EventEmitter, + signal, + input, + output, + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { + MatCard, + MatCardContent, + MatCardHeader, + MatCardTitle, +} from '@angular/material/card'; +import { Task, TaskWithSubtasks } from './services/task.service'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { RoundbuttonComponent } from './roundbutton.component'; +import { MatDividerModule } from '@angular/material/divider'; + +@Component({ + selector: 'app-task', + standalone: true, + imports: [ + MatCard, + MatCardHeader, + MatCardTitle, + MatCardContent, + MatCheckbox, + MatButtonToggleModule, + MatIconModule, + MatButtonModule, + RoundbuttonComponent, + MatDividerModule, + ], + templateUrl: './task.component.html', + styleUrl: './task.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TaskComponent { + task = input(undefined as TaskWithSubtasks | undefined); + canDelete = input(true); + showGeneratedWithGemini = input(false); + + onDelete = output(); + onTasksCompletedToggle = output(); + + onCheckedChanged(subtask: Task) { + subtask.completed = !subtask.completed; + this.onTasksCompletedToggle.emit([subtask]); + } + + onCheckedChangeMainTask(task?: TaskWithSubtasks) { + if (task) { + task.subtasks.forEach((subtask: Task) => { + subtask.completed = !task.maintask.completed; + }); + task.maintask.completed = !task.maintask.completed; + this.onTasksCompletedToggle.emit([task.maintask, ...task.subtasks]); + } + } + + onDeleteClicked() { + const task = this.task(); + if (task) { + this.onDelete.emit(task); + } + } +} diff --git a/src/assets/Spark_Gradient.png b/src/assets/Spark_Gradient.png new file mode 100644 index 0000000..732debc Binary files /dev/null and b/src/assets/Spark_Gradient.png differ diff --git a/src/assets/check.svg b/src/assets/check.svg index 512b468..acfb159 100644 --- a/src/assets/check.svg +++ b/src/assets/check.svg @@ -1,28 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/src/assets/delete.svg b/src/assets/delete.svg new file mode 100644 index 0000000..689670f --- /dev/null +++ b/src/assets/delete.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/gemini.png b/src/assets/gemini.png deleted file mode 100644 index 54fe1c3..0000000 Binary files a/src/assets/gemini.png and /dev/null differ diff --git a/src/assets/image_check.svg b/src/assets/image_check.svg new file mode 100644 index 0000000..1b4a668 --- /dev/null +++ b/src/assets/image_check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/image_unchecked.svg b/src/assets/image_unchecked.svg new file mode 100644 index 0000000..354ff4c --- /dev/null +++ b/src/assets/image_unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/location.png b/src/assets/location.png new file mode 100644 index 0000000..56deb3c Binary files /dev/null and b/src/assets/location.png differ diff --git a/src/assets/room.png b/src/assets/room.png new file mode 100644 index 0000000..0cd6853 Binary files /dev/null and b/src/assets/room.png differ diff --git a/src/assets/unchecked.svg b/src/assets/unchecked.svg new file mode 100644 index 0000000..9446f08 --- /dev/null +++ b/src/assets/unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/styles.scss b/src/styles.scss index b65349b..e09c641 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -59,6 +59,7 @@ html, body { height: 100%; } body { margin: 0; font-family: 'Lexend', 'Helvetica Neue', sans-serif; + background-color: #e2f2fd; } .no-scrollbar::-webkit-scrollbar {