+
+
+
+

+
+ Planning with the Gemini API
+
-
-
-
-
-
- @if (isLoading()) {
+
-
-

-
- Generating suggested task...
-
-
-
- }
-
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 (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 @@
-