diff --git a/.firebaserc b/.firebaserc index a315bfa..af911a2 100644 --- a/.firebaserc +++ b/.firebaserc @@ -2,4 +2,4 @@ "projects": { "default": "watering-reminder" } -} \ No newline at end of file +} diff --git a/firebase.json b/firebase.json index db990c0..b9d6612 100644 --- a/firebase.json +++ b/firebase.json @@ -48,5 +48,8 @@ "ui": { "enabled": true } + }, + "storage": { + "rules": "storage.rules" } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index b557b30..9394af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@angular/platform-browser-dynamic": "~13.3.0", "@angular/router": "~13.3.0", "@angular/service-worker": "~13.3.0", + "ngx-image-cropper": "^6.1.0", "rxjs": "~7.5.0", "tslib": "^2.3.0", "zone.js": "~0.11.4" @@ -8816,6 +8817,18 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/ngx-image-cropper": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ngx-image-cropper/-/ngx-image-cropper-6.1.0.tgz", + "integrity": "sha512-eumBdYVTzujkF9Z4tRCSlJy2FM/hjUZSJwyup8rXG7ntbRdaHT46WaxiiwJblL6xuu5BfRu1V+K0+YzOXaUuew==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=13.0.0", + "@angular/core": ">=13.0.0" + } + }, "node_modules/nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", @@ -19219,6 +19232,14 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "ngx-image-cropper": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ngx-image-cropper/-/ngx-image-cropper-6.1.0.tgz", + "integrity": "sha512-eumBdYVTzujkF9Z4tRCSlJy2FM/hjUZSJwyup8rXG7ntbRdaHT46WaxiiwJblL6xuu5BfRu1V+K0+YzOXaUuew==", + "requires": { + "tslib": "^2.3.0" + } + }, "nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", diff --git a/package.json b/package.json index d2b40c7..e05f2fa 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@angular/platform-browser-dynamic": "~13.3.0", "@angular/router": "~13.3.0", "@angular/service-worker": "~13.3.0", + "ngx-image-cropper": "^6.1.0", "rxjs": "~7.5.0", "tslib": "^2.3.0", "zone.js": "~0.11.4" @@ -38,4 +39,4 @@ "karma-jasmine-html-reporter": "~1.7.0", "typescript": "~4.6.2" } -} \ No newline at end of file +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index a99ac6b..2475729 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -9,6 +9,8 @@ import { map } from 'rxjs' import { UnverifiedComponent } from './pages/unverified/unverified.component' import { ForgotPasswordComponent } from './pages/forgot-password/forgot-password.component' import { ActionComponent } from './pages/action/action.component' +import { AddPlantComponent } from './pages/add-plant/add-plant.component' +import { EditPlantComponent } from './pages/edit-plant/edit-plant.component' const loggedInVerifiedUser: AuthPipeGenerator = () => ( map(user => { @@ -36,6 +38,8 @@ const loggedOutUser: AuthPipeGenerator = () => ( const routes: Routes = [ { path: 'action', component: ActionComponent }, + { path: 'add-plant', component: AddPlantComponent, ...canActivate(loggedInVerifiedUser) }, + { path: 'edit-plant/:id', component: EditPlantComponent, ...canActivate(loggedInVerifiedUser) }, { path: 'dashboard', component: DashboardComponent, ...canActivate(loggedInVerifiedUser) }, { path: 'login', component: LoginComponent, ...canActivate(loggedOutUser) }, { path: 'register', component: RegisterComponent, ...canActivate(loggedOutUser) }, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 2f9b11d..ad5a405 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -10,8 +10,6 @@ import { initializeApp, provideFirebaseApp } from '@angular/fire/app' import { provideAuth, getAuth } from '@angular/fire/auth' import { provideFirestore, getFirestore } from '@angular/fire/firestore' import { provideMessaging, getMessaging } from '@angular/fire/messaging' -import { USE_EMULATOR } from '@angular/fire/compat/auth' -import { SETTINGS } from '@angular/fire/compat/firestore' import { LoginComponent } from './pages/login/login.component' import { RegisterComponent } from './pages/register/register.component' import { IndexComponent } from './pages/index/index.component' @@ -21,6 +19,12 @@ import { ForgotPasswordComponent } from './pages/forgot-password/forgot-password import { ActionComponent } from './pages/action/action.component' import { PasswordResetComponent } from './pages/action/modes/password-reset/password-reset.component' import { EmailVerificationComponent } from './pages/action/modes/email-verification/email-verification.component' +import { PlantsListElementComponent } from './pages/dashboard/plants-list-element/plants-list-element.component' +import { AddPlantComponent } from './pages/add-plant/add-plant.component' +import { PlantFormComponent } from './plant-form/plant-form.component' +import { ImageCropperModule } from 'ngx-image-cropper' +import { getStorage, provideStorage } from '@angular/fire/storage'; +import { EditPlantComponent } from './pages/edit-plant/edit-plant.component' @NgModule({ declarations: [ @@ -34,12 +38,17 @@ import { EmailVerificationComponent } from './pages/action/modes/email-verificat ActionComponent, PasswordResetComponent, EmailVerificationComponent, + PlantsListElementComponent, + AddPlantComponent, + PlantFormComponent, + EditPlantComponent, ], imports: [ BrowserModule, AppRoutingModule, FormsModule, ReactiveFormsModule, + ImageCropperModule, ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production, // Register the ServiceWorker as soon as the application is stable @@ -49,7 +58,8 @@ import { EmailVerificationComponent } from './pages/action/modes/email-verificat provideFirebaseApp(() => initializeApp(environment.firebase)), provideAuth(() => getAuth()), provideFirestore(() => getFirestore()), - provideMessaging(() => getMessaging()) + provideMessaging(() => getMessaging()), + provideStorage(() => getStorage()), ], bootstrap: [AppComponent] }) diff --git a/src/app/models/plant.model.ts b/src/app/models/plant.model.ts new file mode 100644 index 0000000..0de56c9 --- /dev/null +++ b/src/app/models/plant.model.ts @@ -0,0 +1,10 @@ +export interface Plant { + id: string + name: string + description: string + timezone: string + waterTime: string + imageUrl: string +} + +export type PlantWithoutImage = Omit \ No newline at end of file diff --git a/src/app/pages/action/action.component.ts b/src/app/pages/action/action.component.ts index 9aed70c..a609ee5 100644 --- a/src/app/pages/action/action.component.ts +++ b/src/app/pages/action/action.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core' import { ActivatedRoute } from '@angular/router' -import { AuthService } from 'src/app/services/auth.service' +import { AuthService } from 'src/app/services/auth/auth.service' enum Mode { Loading, diff --git a/src/app/pages/action/modes/email-verification/email-verification.component.ts b/src/app/pages/action/modes/email-verification/email-verification.component.ts index 6217c79..5a422dc 100644 --- a/src/app/pages/action/modes/email-verification/email-verification.component.ts +++ b/src/app/pages/action/modes/email-verification/email-verification.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core' import { ActivatedRoute } from '@angular/router' -import { AuthService } from 'src/app/services/auth.service' +import { AuthService } from 'src/app/services/auth/auth.service' enum Status { Loading, diff --git a/src/app/pages/action/modes/password-reset/password-reset.component.ts b/src/app/pages/action/modes/password-reset/password-reset.component.ts index 28485ed..cb42b3e 100644 --- a/src/app/pages/action/modes/password-reset/password-reset.component.ts +++ b/src/app/pages/action/modes/password-reset/password-reset.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core' import { FormControl } from '@angular/forms' import { ActivatedRoute } from '@angular/router' -import { AuthService } from 'src/app/services/auth.service' +import { AuthService } from 'src/app/services/auth/auth.service' enum Status { WaitingForInput, diff --git a/src/app/pages/add-plant/add-plant.component.html b/src/app/pages/add-plant/add-plant.component.html new file mode 100644 index 0000000..854adfa --- /dev/null +++ b/src/app/pages/add-plant/add-plant.component.html @@ -0,0 +1,14 @@ + +
+ + +
+
+
+ Loading +
+ +
+

Error adding plant

+
+
\ No newline at end of file diff --git a/src/app/pages/add-plant/add-plant.component.scss b/src/app/pages/add-plant/add-plant.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/add-plant/add-plant.component.spec.ts b/src/app/pages/add-plant/add-plant.component.spec.ts new file mode 100644 index 0000000..520920c --- /dev/null +++ b/src/app/pages/add-plant/add-plant.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AddPlantComponent } from './add-plant.component'; + +describe('AddPlantComponent', () => { + let component: AddPlantComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AddPlantComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AddPlantComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/add-plant/add-plant.component.ts b/src/app/pages/add-plant/add-plant.component.ts new file mode 100644 index 0000000..e5b0879 --- /dev/null +++ b/src/app/pages/add-plant/add-plant.component.ts @@ -0,0 +1,48 @@ +import { Component } from '@angular/core' +import { FormControl, FormGroup, Validators } from '@angular/forms' +import { Router } from '@angular/router' +import { PlantsService } from 'src/app/services/plants/plants.service' + +enum AddPlantState { + WaitingForInput, + Loading, + Error, +} + +@Component({ + selector: 'app-add-plant', + templateUrl: './add-plant.component.html', + styleUrls: ['./add-plant.component.scss'] +}) +export class AddPlantComponent { + AddPlantState = AddPlantState + + form = new FormGroup({ + name: new FormControl('', Validators.required), + description: new FormControl(''), + timezone: new FormControl(Intl.DateTimeFormat().resolvedOptions().timeZone, Validators.required), + waterTime: new FormControl('', Validators.required), + }) + imageUrl = '' + isCropping = false + addPlantState = AddPlantState.WaitingForInput + + constructor(public plantsService: PlantsService, public router: Router) { } + + async addPlant() { + if (this.canAddPlant) { + try { + this.addPlantState = AddPlantState.Loading + await this.plantsService.addPlant(this.form.value, this.imageUrl) + await this.router.navigate(['/dashboard']) + } catch (error) { + console.error(error) + this.addPlantState = AddPlantState.Error + } + } + } + + get canAddPlant() { + return this.form.valid && !this.isCropping + } +} diff --git a/src/app/pages/dashboard/dashboard.component.html b/src/app/pages/dashboard/dashboard.component.html index a2a0f22..2298edd 100644 --- a/src/app/pages/dashboard/dashboard.component.html +++ b/src/app/pages/dashboard/dashboard.component.html @@ -1,3 +1,23 @@

dashboard works!

- \ No newline at end of file + +
+ + +

Loading

+
+ + +
+ + +

You have no plants.

+ +
+ + + + + + +
\ No newline at end of file diff --git a/src/app/pages/dashboard/dashboard.component.ts b/src/app/pages/dashboard/dashboard.component.ts index 9f6bd63..4cf46d7 100644 --- a/src/app/pages/dashboard/dashboard.component.ts +++ b/src/app/pages/dashboard/dashboard.component.ts @@ -1,6 +1,7 @@ import { Component } from '@angular/core' import { Router } from '@angular/router' -import { AuthService } from 'src/app/services/auth.service' +import { AuthService } from 'src/app/services/auth/auth.service' +import { PlantsService } from 'src/app/services/plants/plants.service' @Component({ selector: 'app-dashboard', @@ -8,12 +9,14 @@ import { AuthService } from 'src/app/services/auth.service' styleUrls: ['./dashboard.component.scss'] }) export class DashboardComponent { + isLoading = true - constructor(public auth: AuthService, public router: Router) { } + constructor(public auth: AuthService, public router: Router, public plantsService: PlantsService) { + this.plantsService.plants$.subscribe(() => this.isLoading = false) + } async logout() { await this.auth.logout() await this.router.navigate(['/login']) } - } diff --git a/src/app/pages/dashboard/plants-list-element/plants-list-element.component.html b/src/app/pages/dashboard/plants-list-element/plants-list-element.component.html new file mode 100644 index 0000000..27f5209 --- /dev/null +++ b/src/app/pages/dashboard/plants-list-element/plants-list-element.component.html @@ -0,0 +1,8 @@ +

{{ plant.name }}

+

{{ plant.description }} {{ plant.waterTime }}

+ +

Loading

+

Could not delete plant

+ +plant image +
\ No newline at end of file diff --git a/src/app/pages/dashboard/plants-list-element/plants-list-element.component.scss b/src/app/pages/dashboard/plants-list-element/plants-list-element.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/dashboard/plants-list-element/plants-list-element.component.spec.ts b/src/app/pages/dashboard/plants-list-element/plants-list-element.component.spec.ts new file mode 100644 index 0000000..6d5dd75 --- /dev/null +++ b/src/app/pages/dashboard/plants-list-element/plants-list-element.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PlantsListElementComponent } from './plants-list-element.component'; + +describe('PlantsListElementComponent', () => { + let component: PlantsListElementComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ PlantsListElementComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PlantsListElementComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/dashboard/plants-list-element/plants-list-element.component.ts b/src/app/pages/dashboard/plants-list-element/plants-list-element.component.ts new file mode 100644 index 0000000..35e7d0c --- /dev/null +++ b/src/app/pages/dashboard/plants-list-element/plants-list-element.component.ts @@ -0,0 +1,35 @@ +import { Component, Input } from '@angular/core' +import { Plant } from 'src/app/models/plant.model' +import { PlantsService } from 'src/app/services/plants/plants.service' + +enum DeleteState { + WaitingForInput, + Loading, + Error, +} + +@Component({ + selector: 'app-plants-list-element', + templateUrl: './plants-list-element.component.html', + styleUrls: ['./plants-list-element.component.scss'] +}) +export class PlantsListElementComponent { + @Input() plant!: Plant + + DeleteState = DeleteState + deleteState = DeleteState.WaitingForInput + + constructor(public plantsService: PlantsService) { } + + async deletePlant() { + try { + this.deleteState = DeleteState.Loading + await this.plantsService.deletePlant(this.plant.id, !!this.plant.imageUrl) + } + catch (e) { + this.deleteState = DeleteState.Error + console.error(e) + } + } + +} diff --git a/src/app/pages/edit-plant/edit-plant.component.html b/src/app/pages/edit-plant/edit-plant.component.html new file mode 100644 index 0000000..1c4439c --- /dev/null +++ b/src/app/pages/edit-plant/edit-plant.component.html @@ -0,0 +1,31 @@ +
+ + + Loading + + + + +
+ + + +
+
+
+ Updating... +
+ +
+

Error updating plant

+
+ +
+ Deleting... +
+ +
+

Error deleting plant

+
+
+
\ No newline at end of file diff --git a/src/app/pages/edit-plant/edit-plant.component.scss b/src/app/pages/edit-plant/edit-plant.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/edit-plant/edit-plant.component.spec.ts b/src/app/pages/edit-plant/edit-plant.component.spec.ts new file mode 100644 index 0000000..7290d2b --- /dev/null +++ b/src/app/pages/edit-plant/edit-plant.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditPlantComponent } from './edit-plant.component'; + +describe('EditPlantComponent', () => { + let component: EditPlantComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ EditPlantComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(EditPlantComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/edit-plant/edit-plant.component.ts b/src/app/pages/edit-plant/edit-plant.component.ts new file mode 100644 index 0000000..3164f5c --- /dev/null +++ b/src/app/pages/edit-plant/edit-plant.component.ts @@ -0,0 +1,91 @@ +import { Component } from '@angular/core' +import { FormControl, FormGroup, Validators } from '@angular/forms' +import { ActivatedRoute, Router } from '@angular/router' +import { Plant, PlantWithoutImage } from 'src/app/models/plant.model' +import { PlantsService } from 'src/app/services/plants/plants.service' + +enum EditPlantState { + LoadingPage, + WaitingForInput, + UpdatingPlant, + UpdatingError, + DeletingPlant, + DeletingError, +} + +@Component({ + selector: 'app-edit-plant', + templateUrl: './edit-plant.component.html', + styleUrls: ['./edit-plant.component.scss'] +}) +export class EditPlantComponent { + EditPlantState = EditPlantState + + originalPlant!: Plant + form!: FormGroup + imageUrl!: string + editPlantState = EditPlantState.LoadingPage + isCropping = false + + constructor(private route: ActivatedRoute, private plantsService: PlantsService, private router: Router) { + this.route.params.subscribe(async (params) => { + try { + this.originalPlant = await plantsService.getPlant(params['id']) + + this.form = new FormGroup({ + name: new FormControl(this.originalPlant.name, Validators.required), + description: new FormControl(this.originalPlant.description), + timezone: new FormControl(this.originalPlant.timezone, Validators.required), + waterTime: new FormControl(this.originalPlant.waterTime, Validators.required), + }) + this.imageUrl = this.originalPlant.imageUrl + + this.editPlantState = EditPlantState.WaitingForInput + } catch (error) { + await this.router.navigate(['/dashboard']) + } + }) + } + + async updatePlant() { + if (this.canUpdatePlant) { + try { + this.editPlantState = EditPlantState.UpdatingPlant + + let dataToUpdate: Partial = {} + + if (this.form.value.name !== this.originalPlant.name) dataToUpdate.name = this.form.value.name + if (this.form.value.description !== this.originalPlant.description) dataToUpdate.description = this.form.value.description + if (this.form.value.timezone !== this.originalPlant.timezone) dataToUpdate.timezone = this.form.value.timezone + if (this.form.value.waterTime !== this.originalPlant.waterTime) dataToUpdate.waterTime = this.form.value.waterTime + + await this.plantsService.updatePlant( + this.originalPlant.id, + dataToUpdate, + this.imageUrl !== this.originalPlant.imageUrl ? this.imageUrl : undefined + ) + await this.router.navigate(['/dashboard']) + } catch (error) { + console.error(error) + this.editPlantState = EditPlantState.UpdatingError + } + } + } + + async deletePlant() { + try { + this.editPlantState = EditPlantState.DeletingPlant + await this.plantsService.deletePlant(this.originalPlant.id, !!this.originalPlant.imageUrl) + await this.router.navigate(['/dashboard']) + } catch (error) { + console.error(error) + this.editPlantState = EditPlantState.DeletingError + } + } + + get canUpdatePlant() { + return this.form.valid && !this.isCropping + } +} + + diff --git a/src/app/pages/forgot-password/forgot-password.component.ts b/src/app/pages/forgot-password/forgot-password.component.ts index 4bc80ca..6049fb8 100644 --- a/src/app/pages/forgot-password/forgot-password.component.ts +++ b/src/app/pages/forgot-password/forgot-password.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core' import { FormControl } from '@angular/forms' -import { AuthService } from 'src/app/services/auth.service' +import { AuthService } from 'src/app/services/auth/auth.service' @Component({ selector: 'app-forgot-password', diff --git a/src/app/pages/login/login.component.ts b/src/app/pages/login/login.component.ts index 6c69bde..e0023db 100644 --- a/src/app/pages/login/login.component.ts +++ b/src/app/pages/login/login.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core' import { FormControl, FormGroup } from '@angular/forms' import { Router } from '@angular/router' import errorCodeToMessage from 'src/app/scripts/errorCodeToMessage' -import { AuthService } from 'src/app/services/auth.service' +import { AuthService } from 'src/app/services/auth/auth.service' enum Status { WaitingForInput, diff --git a/src/app/pages/register/register.component.ts b/src/app/pages/register/register.component.ts index aa85659..01438ee 100644 --- a/src/app/pages/register/register.component.ts +++ b/src/app/pages/register/register.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core' import { FormControl, FormGroup } from '@angular/forms' import errorCodeToMessage from 'src/app/scripts/errorCodeToMessage' -import { AuthService } from 'src/app/services/auth.service' +import { AuthService } from 'src/app/services/auth/auth.service' enum Status { WaitingForInput, diff --git a/src/app/pages/unverified/unverified.component.ts b/src/app/pages/unverified/unverified.component.ts index fe55609..932b14e 100644 --- a/src/app/pages/unverified/unverified.component.ts +++ b/src/app/pages/unverified/unverified.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core' import { Router } from '@angular/router' -import { AuthService } from 'src/app/services/auth.service' +import { AuthService } from 'src/app/services/auth/auth.service' @Component({ selector: 'app-unverified', diff --git a/src/app/plant-form/plant-form.component.html b/src/app/plant-form/plant-form.component.html new file mode 100644 index 0000000..5de4d41 --- /dev/null +++ b/src/app/plant-form/plant-form.component.html @@ -0,0 +1,58 @@ +
+ +
+ Plant name is required. +
+
+ + +
+ + +
+
+ Watering time is required. +
+ + +
+ Timezone is required. +
+
+ +
+ +
+ +
+ + +
+ + + +
+
\ No newline at end of file diff --git a/src/app/plant-form/plant-form.component.scss b/src/app/plant-form/plant-form.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/plant-form/plant-form.component.spec.ts b/src/app/plant-form/plant-form.component.spec.ts new file mode 100644 index 0000000..db5cc30 --- /dev/null +++ b/src/app/plant-form/plant-form.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PlantFormComponent } from './plant-form.component'; + +describe('PlantFormComponent', () => { + let component: PlantFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ PlantFormComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PlantFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/plant-form/plant-form.component.ts b/src/app/plant-form/plant-form.component.ts new file mode 100644 index 0000000..f0882af --- /dev/null +++ b/src/app/plant-form/plant-form.component.ts @@ -0,0 +1,57 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' +import { FormGroup, FormGroupDirective } from '@angular/forms' +import { ImageCroppedEvent } from 'ngx-image-cropper' +import { timezones } from './timezones' + +@Component({ + selector: 'app-plant-form', + templateUrl: './plant-form.component.html', + styleUrls: ['./plant-form.component.scss'] +}) +export class PlantFormComponent implements OnInit { + @Input() imageUrl!: string + @Output() imageUrlChange = new EventEmitter() + + @Input() isCropping!: boolean + @Output() isCroppingChange = new EventEmitter() + + form!: FormGroup + imageChangedEvent: any = ''; + timezones = timezones + + constructor(private rootFormGroup: FormGroupDirective) { } + + ngOnInit() { + this.form = this.rootFormGroup.form + } + + fileChangeEvent(event: any): void { + this.imageChangedEvent = event + this.isCropping = true + this.isCroppingChange.emit(this.isCropping) + } + + imageCropped(event: ImageCroppedEvent) { + this.imageUrl = event.base64 as string + this.imageUrlChange.emit(this.imageUrl) + } + + loadImageFailed() { + // show message + } + + clearImage() { + this.imageChangedEvent = '' + this.imageUrl = '' + this.imageUrlChange.emit(this.imageUrl) + } + + acceptCropping() { + this.isCropping = false + this.isCroppingChange.emit(this.isCropping) + } + + isInvalid(controlName: string) { + return (this.form.controls[controlName].dirty || this.form.controls[controlName].touched) && this.form.controls[controlName].errors?.['required'] + } +} diff --git a/src/app/plant-form/timezones.ts b/src/app/plant-form/timezones.ts new file mode 100644 index 0000000..24a1f62 --- /dev/null +++ b/src/app/plant-form/timezones.ts @@ -0,0 +1,350 @@ +export const timezones = [ + "Africa/Abidjan", + "Africa/Accra", + "Africa/Algiers", + "Africa/Bissau", + "Africa/Cairo", + "Africa/Casablanca", + "Africa/Ceuta", + "Africa/El_Aaiun", + "Africa/Johannesburg", + "Africa/Juba", + "Africa/Khartoum", + "Africa/Lagos", + "Africa/Maputo", + "Africa/Monrovia", + "Africa/Nairobi", + "Africa/Ndjamena", + "Africa/Sao_Tome", + "Africa/Tripoli", + "Africa/Tunis", + "Africa/Windhoek", + "America/Adak", + "America/Anchorage", + "America/Araguaina", + "America/Argentina/Buenos_Aires", + "America/Argentina/Catamarca", + "America/Argentina/Cordoba", + "America/Argentina/Jujuy", + "America/Argentina/La_Rioja", + "America/Argentina/Mendoza", + "America/Argentina/Rio_Gallegos", + "America/Argentina/Salta", + "America/Argentina/San_Juan", + "America/Argentina/San_Luis", + "America/Argentina/Tucuman", + "America/Argentina/Ushuaia", + "America/Asuncion", + "America/Atikokan", + "America/Bahia", + "America/Bahia_Banderas", + "America/Barbados", + "America/Belem", + "America/Belize", + "America/Blanc-Sablon", + "America/Boa_Vista", + "America/Bogota", + "America/Boise", + "America/Cambridge_Bay", + "America/Campo_Grande", + "America/Cancun", + "America/Caracas", + "America/Cayenne", + "America/Chicago", + "America/Chihuahua", + "America/Costa_Rica", + "America/Creston", + "America/Cuiaba", + "America/Curacao", + "America/Danmarkshavn", + "America/Dawson", + "America/Dawson_Creek", + "America/Denver", + "America/Detroit", + "America/Edmonton", + "America/Eirunepe", + "America/El_Salvador", + "America/Fort_Nelson", + "America/Fortaleza", + "America/Glace_Bay", + "America/Godthab", + "America/Goose_Bay", + "America/Grand_Turk", + "America/Guatemala", + "America/Guayaquil", + "America/Guyana", + "America/Halifax", + "America/Havana", + "America/Hermosillo", + "America/Indiana/Indianapolis", + "America/Indiana/Knox", + "America/Indiana/Marengo", + "America/Indiana/Petersburg", + "America/Indiana/Tell_City", + "America/Indiana/Vevay", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Inuvik", + "America/Iqaluit", + "America/Jamaica", + "America/Juneau", + "America/Kentucky/Louisville", + "America/Kentucky/Monticello", + "America/La_Paz", + "America/Lima", + "America/Los_Angeles", + "America/Maceio", + "America/Managua", + "America/Manaus", + "America/Martinique", + "America/Matamoros", + "America/Mazatlan", + "America/Menominee", + "America/Merida", + "America/Metlakatla", + "America/Mexico_City", + "America/Miquelon", + "America/Moncton", + "America/Monterrey", + "America/Montevideo", + "America/Nassau", + "America/New_York", + "America/Nipigon", + "America/Nome", + "America/Noronha", + "America/North_Dakota/Beulah", + "America/North_Dakota/Center", + "America/North_Dakota/New_Salem", + "America/Ojinaga", + "America/Panama", + "America/Pangnirtung", + "America/Paramaribo", + "America/Phoenix", + "America/Port-au-Prince", + "America/Port_of_Spain", + "America/Porto_Velho", + "America/Puerto_Rico", + "America/Punta_Arenas", + "America/Rainy_River", + "America/Rankin_Inlet", + "America/Recife", + "America/Regina", + "America/Resolute", + "America/Rio_Branco", + "America/Santarem", + "America/Santiago", + "America/Santo_Domingo", + "America/Sao_Paulo", + "America/Scoresbysund", + "America/Sitka", + "America/St_Johns", + "America/Swift_Current", + "America/Tegucigalpa", + "America/Thule", + "America/Thunder_Bay", + "America/Tijuana", + "America/Toronto", + "America/Vancouver", + "America/Whitehorse", + "America/Winnipeg", + "America/Yakutat", + "America/Yellowknife", + "Antarctica/Casey", + "Antarctica/Davis", + "Antarctica/DumontDUrville", + "Antarctica/Macquarie", + "Antarctica/Mawson", + "Antarctica/Palmer", + "Antarctica/Rothera", + "Antarctica/Syowa", + "Antarctica/Troll", + "Antarctica/Vostok", + "Asia/Almaty", + "Asia/Amman", + "Asia/Anadyr", + "Asia/Aqtau", + "Asia/Aqtobe", + "Asia/Ashgabat", + "Asia/Atyrau", + "Asia/Baghdad", + "Asia/Baku", + "Asia/Bangkok", + "Asia/Barnaul", + "Asia/Beirut", + "Asia/Bishkek", + "Asia/Brunei", + "Asia/Chita", + "Asia/Choibalsan", + "Asia/Colombo", + "Asia/Damascus", + "Asia/Dhaka", + "Asia/Dili", + "Asia/Dubai", + "Asia/Dushanbe", + "Asia/Famagusta", + "Asia/Gaza", + "Asia/Hebron", + "Asia/Ho_Chi_Minh", + "Asia/Hong_Kong", + "Asia/Hovd", + "Asia/Irkutsk", + "Asia/Jakarta", + "Asia/Jayapura", + "Asia/Jerusalem", + "Asia/Kabul", + "Asia/Kamchatka", + "Asia/Karachi", + "Asia/Kathmandu", + "Asia/Khandyga", + "Asia/Kolkata", + "Asia/Krasnoyarsk", + "Asia/Kuala_Lumpur", + "Asia/Kuching", + "Asia/Macau", + "Asia/Magadan", + "Asia/Makassar", + "Asia/Manila", + "Asia/Nicosia", + "Asia/Novokuznetsk", + "Asia/Novosibirsk", + "Asia/Omsk", + "Asia/Oral", + "Asia/Pontianak", + "Asia/Pyongyang", + "Asia/Qatar", + "Asia/Qostanay", + "Asia/Qyzylorda", + "Asia/Riyadh", + "Asia/Sakhalin", + "Asia/Samarkand", + "Asia/Seoul", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Srednekolymsk", + "Asia/Taipei", + "Asia/Tashkent", + "Asia/Tbilisi", + "Asia/Tehran", + "Asia/Thimphu", + "Asia/Tokyo", + "Asia/Tomsk", + "Asia/Ulaanbaatar", + "Asia/Urumqi", + "Asia/Ust-Nera", + "Asia/Vladivostok", + "Asia/Yakutsk", + "Asia/Yangon", + "Asia/Yekaterinburg", + "Asia/Yerevan", + "Atlantic/Azores", + "Atlantic/Bermuda", + "Atlantic/Canary", + "Atlantic/Cape_Verde", + "Atlantic/Faroe", + "Atlantic/Madeira", + "Atlantic/Reykjavik", + "Atlantic/South_Georgia", + "Atlantic/Stanley", + "Australia/Adelaide", + "Australia/Brisbane", + "Australia/Broken_Hill", + "Australia/Currie", + "Australia/Darwin", + "Australia/Eucla", + "Australia/Hobart", + "Australia/Lindeman", + "Australia/Lord_Howe", + "Australia/Melbourne", + "Australia/Perth", + "Australia/Sydney", + "Europe/Amsterdam", + "Europe/Andorra", + "Europe/Astrakhan", + "Europe/Athens", + "Europe/Belgrade", + "Europe/Berlin", + "Europe/Brussels", + "Europe/Bucharest", + "Europe/Budapest", + "Europe/Chisinau", + "Europe/Copenhagen", + "Europe/Dublin", + "Europe/Gibraltar", + "Europe/Helsinki", + "Europe/Istanbul", + "Europe/Kaliningrad", + "Europe/Kiev", + "Europe/Kirov", + "Europe/Lisbon", + "Europe/London", + "Europe/Luxembourg", + "Europe/Madrid", + "Europe/Malta", + "Europe/Minsk", + "Europe/Monaco", + "Europe/Moscow", + "Europe/Oslo", + "Europe/Paris", + "Europe/Prague", + "Europe/Riga", + "Europe/Rome", + "Europe/Samara", + "Europe/Saratov", + "Europe/Simferopol", + "Europe/Sofia", + "Europe/Stockholm", + "Europe/Tallinn", + "Europe/Tirane", + "Europe/Ulyanovsk", + "Europe/Uzhgorod", + "Europe/Vienna", + "Europe/Vilnius", + "Europe/Volgograd", + "Europe/Warsaw", + "Europe/Zaporozhye", + "Europe/Zurich", + "Indian/Chagos", + "Indian/Christmas", + "Indian/Cocos", + "Indian/Kerguelen", + "Indian/Mahe", + "Indian/Maldives", + "Indian/Mauritius", + "Indian/Reunion", + "Pacific/Apia", + "Pacific/Auckland", + "Pacific/Bougainville", + "Pacific/Chatham", + "Pacific/Chuuk", + "Pacific/Easter", + "Pacific/Efate", + "Pacific/Enderbury", + "Pacific/Fakaofo", + "Pacific/Fiji", + "Pacific/Funafuti", + "Pacific/Galapagos", + "Pacific/Gambier", + "Pacific/Guadalcanal", + "Pacific/Guam", + "Pacific/Honolulu", + "Pacific/Kiritimati", + "Pacific/Kosrae", + "Pacific/Kwajalein", + "Pacific/Majuro", + "Pacific/Marquesas", + "Pacific/Nauru", + "Pacific/Niue", + "Pacific/Norfolk", + "Pacific/Noumea", + "Pacific/Pago_Pago", + "Pacific/Palau", + "Pacific/Pitcairn", + "Pacific/Pohnpei", + "Pacific/Port_Moresby", + "Pacific/Rarotonga", + "Pacific/Tahiti", + "Pacific/Tarawa", + "Pacific/Tongatapu", + "Pacific/Wake", + "Pacific/Wallis" +] \ No newline at end of file diff --git a/src/app/services/auth.service.spec.ts b/src/app/services/auth/auth.service.spec.ts similarity index 100% rename from src/app/services/auth.service.spec.ts rename to src/app/services/auth/auth.service.spec.ts diff --git a/src/app/services/auth.service.ts b/src/app/services/auth/auth.service.ts similarity index 100% rename from src/app/services/auth.service.ts rename to src/app/services/auth/auth.service.ts diff --git a/src/app/services/plants/plants.service.spec.ts b/src/app/services/plants/plants.service.spec.ts new file mode 100644 index 0000000..aa99b23 --- /dev/null +++ b/src/app/services/plants/plants.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing' + +import { PlantsService } from './plants.service' + +describe('PlantsService', () => { + let service: PlantsService + + beforeEach(() => { + TestBed.configureTestingModule({}) + service = TestBed.inject(PlantsService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) +}) diff --git a/src/app/services/plants/plants.service.ts b/src/app/services/plants/plants.service.ts new file mode 100644 index 0000000..c4c2dfa --- /dev/null +++ b/src/app/services/plants/plants.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@angular/core' +import { collection, collectionData, CollectionReference, doc, Firestore } from '@angular/fire/firestore' +import { addDoc, deleteDoc, updateDoc, getDoc, DocumentReference } from '@firebase/firestore' +import { from, lastValueFrom, map, Observable, of, switchMap, take } from 'rxjs' +import { Plant, PlantWithoutImage } from 'src/app/models/plant.model' +import { AuthService } from '../auth/auth.service' +import { ref, Storage, uploadBytes, getDownloadURL, deleteObject } from '@angular/fire/storage' + +@Injectable({ + providedIn: 'root' +}) +export class PlantsService { + plants$: Observable + plantsAreEmpty$: Observable + + constructor(private auth: AuthService, private firestore: Firestore, private storage: Storage) { + this.plants$ = this.auth.user$.pipe( + switchMap((user) => { + if (user) { + const c = collection(this.firestore, "users", user.uid, "plants") as CollectionReference + return collectionData(c, { idField: "id" }) + } + return of([]) + }) + ) + + this.plantsAreEmpty$ = this.plants$.pipe(map(plants => plants.length === 0)) + } + + async addPlant(plantWithoutImage: PlantWithoutImage, imageDataUrl: string) { + const user = await lastValueFrom(this.auth.user$.pipe(take(1))) + + if (user) { + const c = collection(this.firestore, "users", user.uid, "plants") + const docRef = await addDoc(c, plantWithoutImage) + + let imageUrl = '' + if (imageDataUrl !== '') { + const imgResp = await fetch(imageDataUrl) + const imgBlob = await imgResp.blob() + const imgRef = ref(this.storage, `users/${user.uid}/plants/${docRef.id}`) + await uploadBytes(imgRef, imgBlob) + imageUrl = await getDownloadURL(imgRef) + } + + await updateDoc(docRef, { imageUrl }) + return + } + + throw new Error("User is not logged in") + } + + async deletePlant(plantID: string, hasImage: boolean) { + const user = await lastValueFrom(this.auth.user$.pipe(take(1))) + + if (user) { + const docRef = doc(this.firestore, "users", user.uid, "plants", plantID) + await deleteDoc(docRef) + + if (hasImage) { + const imgRef = ref(this.storage, `users/${user.uid}/plants/${plantID}`) + await deleteObject(imgRef) + } + return + } + + throw new Error("User is not logged in") + } + + async updatePlant(plantID: string, plantWithoutImage: Partial, imageDataUrl?: string) { + const user = await lastValueFrom(this.auth.user$.pipe(take(1))) + + if (user) { + let imageUrl: string | undefined = undefined + + if (imageDataUrl === '') { + const imgRef = ref(this.storage, `users/${user.uid}/plants/${plantID}`) + await deleteObject(imgRef) + imageUrl = '' + } + else if (imageDataUrl !== undefined) { + const imgResp = await fetch(imageDataUrl) + const imgBlob = await imgResp.blob() + const imgRef = ref(this.storage, `users/${user.uid}/plants/${plantID}`) + await uploadBytes(imgRef, imgBlob) + imageUrl = await getDownloadURL(imgRef) + } + + let plant: Partial = plantWithoutImage + if (imageUrl !== undefined) { + plant = { ...plant, imageUrl } + } + + const d = doc(this.firestore, "users", user.uid, "plants", plantID) + await updateDoc(d, plant) + + return + } + + throw new Error("User is not logged in") + } + + async getPlant(plantID: string): Promise { + const user = await lastValueFrom(this.auth.user$.pipe(take(1))) + + if (user) { + const d = doc(this.firestore, "users", user.uid, "plants", plantID) as DocumentReference + let plant = (await getDoc(d)).data() + + if (!plant) { + throw new Error("Plant does not exist") + } + + plant.id = plantID + return plant + } + + throw new Error("User is not logged in") + } +} diff --git a/src/assets/images/default_plant_image.png b/src/assets/images/default_plant_image.png new file mode 100644 index 0000000..ec66419 Binary files /dev/null and b/src/assets/images/default_plant_image.png differ diff --git a/storage.rules b/storage.rules new file mode 100644 index 0000000..4eda34f --- /dev/null +++ b/storage.rules @@ -0,0 +1,8 @@ +rules_version = '2'; +service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if request.auth!=null; + } + } +}