diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 00000000..7626514d --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "fitness-app-e668a" + } +} diff --git a/database.rules.json b/database.rules.json new file mode 100644 index 00000000..f36a9ddd --- /dev/null +++ b/database.rules.json @@ -0,0 +1,31 @@ +{ + "rules": { + "users": { + "$uid": { + ".read": "$uid === auth.uid", + ".write": "$uid === auth.uid" + } + }, + "schedule": { + "$uid": { + ".read": "$uid === auth.uid", + ".write": "$uid === auth.uid", + ".indexOn": [ + "timestamp" + ] + } + }, + "meals": { + "$uid": { + ".read": "$uid === auth.uid", + ".write": "$uid === auth.uid" + } + }, + "workouts": { + "$uid": { + ".read": "$uid === auth.uid", + ".write": "$uid === auth.uid" + } + } + } +} diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..6ae74fbe --- /dev/null +++ b/firebase.json @@ -0,0 +1,30 @@ +{ + "database": { + "rules": "database.rules.json" + }, + "hosting": { + "public": "", + "ignore": [ + "firebase.json", + ".firebaserc", + ".vscode", + ".git", + ".gitignore", + ".editorconfig", + "src/**/.*", + "database.rules.json", + "package.json", + "README.md", + "tsconfig.json", + "webpack.config.js", + "yarn.lock", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + } +} \ No newline at end of file diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 84f8a6c9..7f105682 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -5,22 +5,32 @@ import { Routes, RouterModule } from '@angular/router'; import { Store } from 'store'; // feature modules +import { AuthModule } from '../auth/auth.module'; +import { HealthModule } from '../health/health.module'; // containers import { AppComponent } from './containers/app/app.component'; // components +import { AppHeaderComponent } from './components/app-header/app-header.component'; +import { AppNavComponent } from './components/app-nav/app-nav.component'; // routes -export const ROUTES: Routes = []; +export const ROUTES: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'schedule' } +]; @NgModule({ imports: [ BrowserModule, - RouterModule.forRoot(ROUTES) + RouterModule.forRoot(ROUTES), + AuthModule, + HealthModule ], declarations: [ - AppComponent + AppComponent, + AppHeaderComponent, + AppNavComponent ], providers: [ Store @@ -29,4 +39,4 @@ export const ROUTES: Routes = []; AppComponent ] }) -export class AppModule {} +export class AppModule {} \ No newline at end of file diff --git a/src/app/components/app-header/app-header.component.scss b/src/app/components/app-header/app-header.component.scss new file mode 100644 index 00000000..568c7adb --- /dev/null +++ b/src/app/components/app-header/app-header.component.scss @@ -0,0 +1,33 @@ +.app-header { + background: #fff; + border-bottom: 1px solid #c1cedb; + padding: 15px 0; + text-align: center; + img { + display: inline-block; + } + &__user-info { + position: absolute; + top: 16px; + right: 0; + cursor: pointer; + } + span { + background: url(/img/logout.svg) no-repeat; + background-size: contain; + width: 24px; + height: 24px; + display: block; + opacity: 0.4; + &:hover { + opacity: 0.9; + } + } +} + +.wrapper { + max-width: 800px; + width: 96%; + margin: 0 auto; + position: relative; +} diff --git a/src/app/components/app-header/app-header.component.ts b/src/app/components/app-header/app-header.component.ts new file mode 100644 index 00000000..276ab13d --- /dev/null +++ b/src/app/components/app-header/app-header.component.ts @@ -0,0 +1,34 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; + +import { User } from '../../../auth/shared/services/auth/auth.service'; + +@Component({ + selector: 'app-header', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['app-header.component.scss'], + template: ` +
+
+ + +
+
+ ` +}) +export class AppHeaderComponent { + + @Input() + user: User; + + @Output() + logout = new EventEmitter(); + + logoutUser() { + this.logout.emit(); + } + +} \ No newline at end of file diff --git a/src/app/components/app-nav/app-nav.component.scss b/src/app/components/app-nav/app-nav.component.scss new file mode 100644 index 00000000..e5f52424 --- /dev/null +++ b/src/app/components/app-nav/app-nav.component.scss @@ -0,0 +1,28 @@ +:host { + margin: -1px 0 0; + display: block; +} +.app-nav { + background: #8022b0; + text-align: center; + a { + color: rgba(255,255,255,.6); + padding: 15px 0; + display: inline-block; + min-width: 150px; + font-weight: 500; + font-size: 16px; + text-transform: uppercase; + border-bottom: 3px solid transparent; + &:hover, + &.active { + color: #fff; + border-bottom-color: #fff; + } + } +} +.wrapper { + max-width: 800px; + width: 96%; + margin: 0 auto; +} \ No newline at end of file diff --git a/src/app/components/app-nav/app-nav.component.ts b/src/app/components/app-nav/app-nav.component.ts new file mode 100644 index 00000000..f0a494d5 --- /dev/null +++ b/src/app/components/app-nav/app-nav.component.ts @@ -0,0 +1,19 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'app-nav', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['app-nav.component.scss'], + template: ` +
+ +
+ ` +}) +export class AppNavComponent { + constructor() {} +} \ No newline at end of file diff --git a/src/app/containers/app/app.component.ts b/src/app/containers/app/app.component.ts index 74a8cb58..60e27796 100755 --- a/src/app/containers/app/app.component.ts +++ b/src/app/containers/app/app.component.ts @@ -1,14 +1,54 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Observable } from 'rxjs/Observable'; +import { Subscription } from 'rxjs/Subscription'; + +import { Store } from 'store'; + +import { AuthService, User } from '../../../auth/shared/services/auth/auth.service'; @Component({ selector: 'app-root', styleUrls: ['app.component.scss'], template: `
- Hello Ultimate Angular! + + + + +
+ +
` }) -export class AppComponent { - constructor() {} +export class AppComponent implements OnInit, OnDestroy { + + user$: Observable; + subscription: Subscription; + + constructor( + private store: Store, + private router: Router, + private authService: AuthService + ) {} + + ngOnInit() { + this.subscription = this.authService.auth$.subscribe(); + this.user$ = this.store.select('user'); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } + + async onLogout() { + await this.authService.logoutUser(); + this.router.navigate(['/auth/login']); + } + } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 00000000..6ac2df66 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,43 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; + +// third-party modules +import { AngularFireModule, FirebaseAppConfig } from 'angularfire2'; +import { AngularFireAuthModule } from 'angularfire2/auth'; +import { AngularFireDatabaseModule } from 'angularfire2/database'; + +// shared modules +import { SharedModule } from './shared/shared.module'; + +export const ROUTES: Routes = [ + { + path: 'auth', + children: [ + { path: '', pathMatch: 'full', redirectTo: 'login' }, + { path: 'login', loadChildren: './login/login.module#LoginModule' }, + { path: 'register', loadChildren: './register/register.module#RegisterModule' }, + ] + } +]; + +export const firebaseConfig: FirebaseAppConfig = { + apiKey: "AIzaSyCXz7GrHLBs-xlsCrr185iG4v4UrNreq2Y", + authDomain: "fitness-app-e668a.firebaseapp.com", + databaseURL: "https://fitness-app-e668a.firebaseio.com", + projectId: "fitness-app-e668a", + storageBucket: "fitness-app-e668a.appspot.com", + messagingSenderId: "1014564696462" +}; + +@NgModule({ + imports: [ + CommonModule, + RouterModule.forChild(ROUTES), + AngularFireModule.initializeApp(firebaseConfig), + AngularFireAuthModule, + AngularFireDatabaseModule, + SharedModule.forRoot() + ] +}) +export class AuthModule {} \ No newline at end of file diff --git a/src/auth/login/containers/login/login.component.ts b/src/auth/login/containers/login/login.component.ts new file mode 100644 index 00000000..31968f60 --- /dev/null +++ b/src/auth/login/containers/login/login.component.ts @@ -0,0 +1,42 @@ +import { Component } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { AuthService } from '../../../shared/services/auth/auth.service'; + +@Component({ + selector: 'login', + template: ` +
+ +

Login

+ Not registered? + +
+ {{ error }} +
+
+
+ ` +}) +export class LoginComponent { + + error: string; + + constructor( + private authService: AuthService, + private router: Router + ) {} + + async loginUser(event: FormGroup) { + const { email, password } = event.value; + try { + await this.authService.loginUser(email, password); + this.router.navigate(['/']); + } catch (err) { + this.error = err.message; + } + } +} \ No newline at end of file diff --git a/src/auth/login/login.module.ts b/src/auth/login/login.module.ts new file mode 100644 index 00000000..ae02bb64 --- /dev/null +++ b/src/auth/login/login.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; + +import { SharedModule } from '../shared/shared.module'; + +import { LoginComponent } from './containers/login/login.component'; + +export const ROUTES: Routes = [ + { path: '', component: LoginComponent } +]; + +@NgModule({ + imports: [ + CommonModule, + RouterModule.forChild(ROUTES), + SharedModule + ], + declarations: [ + LoginComponent + ] +}) +export class LoginModule {} \ No newline at end of file diff --git a/src/auth/register/containers/register/register.component.ts b/src/auth/register/containers/register/register.component.ts new file mode 100644 index 00000000..ecb8f005 --- /dev/null +++ b/src/auth/register/containers/register/register.component.ts @@ -0,0 +1,42 @@ +import { Component } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { AuthService } from '../../../shared/services/auth/auth.service'; + +@Component({ + selector: 'register', + template: ` +
+ +

Register

+ Already have an account? + +
+ {{ error }} +
+
+
+ ` +}) +export class RegisterComponent { + + error: string; + + constructor( + private authService: AuthService, + private router: Router + ) {} + + async registerUser(event: FormGroup) { + const { email, password } = event.value; + try { + await this.authService.createUser(email, password); + this.router.navigate(['/']); + } catch (err) { + this.error = err.message; + } + } +} \ No newline at end of file diff --git a/src/auth/register/register.module.ts b/src/auth/register/register.module.ts new file mode 100644 index 00000000..cb74c34c --- /dev/null +++ b/src/auth/register/register.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; + +import { SharedModule } from '../shared/shared.module'; + +import { RegisterComponent } from './containers/register/register.component'; + +export const ROUTES: Routes = [ + { path: '', component: RegisterComponent } +]; + +@NgModule({ + imports: [ + CommonModule, + RouterModule.forChild(ROUTES), + SharedModule + ], + declarations: [ + RegisterComponent + ] +}) +export class RegisterModule {} \ No newline at end of file diff --git a/src/auth/shared/components/auth-form/auth-form.component.scss b/src/auth/shared/components/auth-form/auth-form.component.scss new file mode 100644 index 00000000..523df17e --- /dev/null +++ b/src/auth/shared/components/auth-form/auth-form.component.scss @@ -0,0 +1,90 @@ +:host ::ng-deep { + .error { + color: #a94442; + background: #f2dede; + border: 1px solid #e4b3b3; + border-radius: 2px; + padding: 8px; + font-size: 14px; + font-weight: 400; + margin: 10px 0 0; + } + h1 { + margin: 0 0 25px; + font-size: 20px; + font-weight: 600; + text-align: center; + } + button { + cursor: pointer; + outline: 0; + width: 100%; + border-radius: 2px; + border: 1px solid #1c79b8; + background: #39a1e7; + color: #fff; + padding: 10px; + font-size: 16px; + font-weight: 600; + transition: all 0.2s ease-in-out; + &:hover { + background: darken(#39a1e7, 5%); + border-color: darken(#1c79b8, 5%); + } + &:disabled { + opacity: .4; + cursor: not-allowed; + } + } + a { + display: block; + text-align: center; + color: #5e7386; + font-size: 14px; + } +} + +.auth-form { + background: #fff; + box-shadow: 0 3px 4px rgba(0,0,0,.1); + border-radius: 3px; + border: 1px solid #c1cedb; + width: 400px; + margin: 50px auto; + padding: 30px; + &__action { + margin: 10px 0 30px; + } + &__toggle { + border-radius: 0 0 3px 3px; + border-top: 1px solid #c1cedb; + background: #f8fafc; + padding: 10px; + margin: 0 -30px -30px; + } + label { + display: block; + margin: 0; + } + input { + outline: 0; + font-size: 16px; + padding: 10px 15px; + margin: 0; + width: 100%; + background: #fafcfd; + color: #5777a8; + border: 1px solid #d1deeb; + text-align: center; + &::-webkit-input-placeholder { + color: #5777a8; + } + &[type=email] { + border-radius: 3px 3px 0 0; + } + &[type=password] { + border-radius: 0 0 3px 3px; + margin: -1px 0 0; + } + } +} \ No newline at end of file diff --git a/src/auth/shared/components/auth-form/auth-form.component.ts b/src/auth/shared/components/auth-form/auth-form.component.ts new file mode 100644 index 00000000..3a9b23ef --- /dev/null +++ b/src/auth/shared/components/auth-form/auth-form.component.ts @@ -0,0 +1,78 @@ +import { Component, Output, EventEmitter } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +@Component({ + selector: 'auth-form', + styleUrls: ['auth-form.component.scss'], + template: ` +
+
+ + + + + + +
+ Invalid email format +
+ +
+ Password is required +
+ + + +
+ +
+ +
+ +
+ +
+
+ ` +}) +export class AuthFormComponent { + + @Output() + submitted = new EventEmitter(); + + form = this.fb.group({ + email: ['', Validators.email], + password: ['', Validators.required] + }); + + constructor( + private fb: FormBuilder + ) {} + + onSubmit() { + if (this.form.valid) { + this.submitted.emit(this.form); + } + } + + get passwordInvalid() { + const control = this.form.get('password'); + return control.hasError('required') && control.touched; + } + + get emailFormat() { + const control = this.form.get('email'); + return control.hasError('email') && control.touched; + } + +} \ No newline at end of file diff --git a/src/auth/shared/guards/auth.guard.ts b/src/auth/shared/guards/auth.guard.ts new file mode 100644 index 00000000..d1ce3b4e --- /dev/null +++ b/src/auth/shared/guards/auth.guard.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { Router, CanActivate } from '@angular/router'; + +import 'rxjs/add/operator/map'; + +import { AuthService } from '../services/auth/auth.service'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private router: Router, + private authService: AuthService + ) {} + + canActivate() { + return this.authService.authState + .map((user) => { + if (!user) { + this.router.navigate(['/auth/login']); + } + return !!user; + }); + } +} \ No newline at end of file diff --git a/src/auth/shared/services/auth/auth.service.ts b/src/auth/shared/services/auth/auth.service.ts new file mode 100644 index 00000000..d4551fa4 --- /dev/null +++ b/src/auth/shared/services/auth/auth.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; + +import { Store } from 'store'; + +import 'rxjs/add/operator/do'; + +import { AngularFireAuth } from 'angularfire2/auth'; + +export interface User { + email: string, + uid: string, + authenticated: boolean +} + +@Injectable() +export class AuthService { + + auth$ = this.af.authState + .do(next => { + if (!next) { + this.store.set('user', null); + return; + } + const user: User = { + email: next.email, + uid: next.uid, + authenticated: true + }; + this.store.set('user', user); + }); + + constructor( + private store: Store, + private af: AngularFireAuth + ) {} + + get user() { + return this.af.auth.currentUser; + } + + get authState() { + return this.af.authState; + } + + createUser(email: string, password: string) { + return this.af.auth + .createUserWithEmailAndPassword(email, password); + } + + loginUser(email: string, password: string) { + return this.af.auth + .signInWithEmailAndPassword(email, password); + } + + logoutUser() { + return this.af.auth.signOut(); + } + +} \ No newline at end of file diff --git a/src/auth/shared/shared.module.ts b/src/auth/shared/shared.module.ts new file mode 100644 index 00000000..4f62383d --- /dev/null +++ b/src/auth/shared/shared.module.ts @@ -0,0 +1,36 @@ +import { NgModule, ModuleWithProviders } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; + +// components +import { AuthFormComponent } from './components/auth-form/auth-form.component'; + +// services +import { AuthService } from './services/auth/auth.service'; + +// guards +import { AuthGuard } from './guards/auth.guard'; + +@NgModule({ + imports: [ + CommonModule, + ReactiveFormsModule + ], + declarations: [ + AuthFormComponent + ], + exports: [ + AuthFormComponent + ] +}) +export class SharedModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: SharedModule, + providers: [ + AuthService, + AuthGuard + ] + }; + } +} \ No newline at end of file diff --git a/src/health/health.module.ts b/src/health/health.module.ts new file mode 100644 index 00000000..614e5148 --- /dev/null +++ b/src/health/health.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +// shared modules +import { SharedModule } from './shared/shared.module'; + +// guards +import { AuthGuard } from '../auth/shared/guards/auth.guard'; + +export const ROUTES: Routes = [ + { path: 'schedule', canActivate: [AuthGuard], loadChildren: './schedule/schedule.module#ScheduleModule' }, + { path: 'meals', canActivate: [AuthGuard], loadChildren: './meals/meals.module#MealsModule' }, + { path: 'workouts', canActivate: [AuthGuard], loadChildren: './workouts/workouts.module#WorkoutsModule' } +]; + +@NgModule({ + imports: [ + RouterModule.forChild(ROUTES), + SharedModule.forRoot() + ] +}) +export class HealthModule {} \ No newline at end of file diff --git a/src/health/meals/components/meal-form/meal-form.component.scss b/src/health/meals/components/meal-form/meal-form.component.scss new file mode 100644 index 00000000..e40a0a94 --- /dev/null +++ b/src/health/meals/components/meal-form/meal-form.component.scss @@ -0,0 +1,181 @@ +%button { + outline: 0; + cursor: pointer; + border: 0; + background: transparent; +} +.confirm, +.cancel { + @extend %button; + padding: 5px 10px; + margin: 0 0 0 5px; + font-size: 14px; +} +.error { + color: #a94442; + background: #f2dede; + border: 1px solid #e4b3b3; + border-radius: 2px; + padding: 8px; + font-size: 14px; + font-weight: 400; + margin: 10px 0 0; +} +.confirm { + color: #fff; + background: #d73a49; + border-radius: 3px; + transition: all .2s ease-in-out; + &:hover { + background: darken(#d73a49, 3%); + } +} + +.meal-form { + &__name { + padding: 30px; + flex-direction: column; + border-bottom: 1px solid #d1deeb; + } + &__food { + padding: 30px; + border-bottom: 1px solid #d1deeb; + } + &__subtitle { + display: flex; + align-items: center; + h3 { + margin: 20px 0; + flex-grow: 1; + } + } + &__delete { + display: flex; + align-items: center; + > div { + display: flex; + align-items: center; + p { + margin: 0; + } + } + .cancel { + margin: 0 20px 0 0; + } + } + &__add { + display: flex; + align-items: center; + color: #fff; + border: 0; + outline: 0; + cursor: pointer; + background: #97c747; + border-radius: 50px; + padding: 6px 20px 6px 15px; + text-transform: uppercase; + font-weight: 600; + font-size: 13px; + img { + width: 20px; + margin: 0 6px 0 0; + } + } + &__remove { + cursor: pointer; + background-image: url(/img/cross.svg); + background-size: 15px 15px; + background-repeat: no-repeat; + background-position: center center; + background-color: #eff4f9; + width: 35px; + height: 38px; + display: block; + position: absolute; + top: 1px; + right: 1px; + border-left: 1px solid #d1deeb; + transition: all .2s ease-in-out; + &:hover { + background-color: darken(#eff4f9, 5%); + } + } + &__submit { + display: flex; + justify-content: space-between; + padding: 30px; + } + h1 { + flex-grow: 1; + display: flex; + align-items: center; + margin: 0; + padding: 0; + font-size: 24px; + img { + margin: 0 10px 0 0; + } + } + h3 { + font-size: 18px; + font-weight: 600; + } + label { + position: relative; + display: block; + margin: 0 0 10px; + } + input { + outline: 0; + font-size: 16px; + padding: 10px 40px 10px 15px; + margin: 0; + width: 100%; + background: #fff; + color: #545e6f; + flex-grow: 1; + border: 1px solid #d1deeb; + border-radius: 3px; + transition: all 0.2s ease-in-out; + &:focus { + border-color: #a5b9ce; + } + &::-webkit-input-placeholder { + color: #aaa; + } + } + .button { + cursor: pointer; + outline: 0; + border: 0; + border-radius: 2px; + background: #39a1e7; + color: #fff; + padding: 10px 18px; + font-size: 16px; + font-weight: 600; + transition: all 0.2s ease-in-out; + display: inline-block; + &:hover { + background: darken(#39a1e7, 5%); + } + &:disabled { + opacity: .4; + cursor: not-allowed; + } + &--cancel { + background: #fff; + color: #545e6f; + &:hover { + background: #fff; + } + } + &--delete { + background: #d73a49; + align-self: flex-start; + &:hover { + background: darken(#d73a49, 5%); + } + } + } +} diff --git a/src/health/meals/components/meal-form/meal-form.component.ts b/src/health/meals/components/meal-form/meal-form.component.ts new file mode 100644 index 00000000..f6eb7025 --- /dev/null +++ b/src/health/meals/components/meal-form/meal-form.component.ts @@ -0,0 +1,190 @@ +import { Component, OnChanges, SimpleChanges, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { FormArray, FormGroup, FormBuilder, FormControl, Validators } from '@angular/forms'; + +import { Meal } from '../../../shared/services/meals/meals.service'; + +@Component({ + selector: 'meal-form', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['meal-form.component.scss'], + template: ` +
+ +
+ +
+ +
+ +
+
+

Food

+ +
+
+ +
+
+ +
+
+ + + + Cancel + +
+ +
+
+

Delete item?

+ + +
+ + +
+ +
+ +
+ +
+ ` +}) +export class MealFormComponent implements OnChanges { + + toggled = false; + exists = false; + + @Input() + meal: Meal; + + @Output() + create = new EventEmitter(); + + @Output() + update = new EventEmitter(); + + @Output() + remove = new EventEmitter(); + + form = this.fb.group({ + name: ['', Validators.required], + ingredients: this.fb.array(['']) + }); + + constructor( + private fb: FormBuilder + ) {} + + ngOnChanges(changes: SimpleChanges) { + if (this.meal && this.meal.name) { + this.exists = true; + this.emptyIngredients(); + + const value = this.meal; + this.form.patchValue(value); + + if (value.ingredients) { + for (const item of value.ingredients) { + this.ingredients.push(new FormControl(item)); + } + } + + } + } + + emptyIngredients() { + while(this.ingredients.controls.length) { + this.ingredients.removeAt(0); + } + } + + get required() { + return ( + this.form.get('name').hasError('required') && + this.form.get('name').touched + ); + } + + get ingredients() { + return this.form.get('ingredients') as FormArray; + } + + addIngredient() { + this.ingredients.push(new FormControl('')); + } + + removeIngredient(index: number) { + this.ingredients.removeAt(index); + } + + createMeal() { + if (this.form.valid) { + this.create.emit(this.form.value); + } + } + + updateMeal() { + if (this.form.valid) { + this.update.emit(this.form.value); + } + } + + removeMeal() { + this.remove.emit(this.form.value); + } + + toggle() { + this.toggled = !this.toggled; + } + +} \ No newline at end of file diff --git a/src/health/meals/containers/meal/meal.component.scss b/src/health/meals/containers/meal/meal.component.scss new file mode 100644 index 00000000..c031c5bc --- /dev/null +++ b/src/health/meals/containers/meal/meal.component.scss @@ -0,0 +1,42 @@ +:host { + display: block; + margin: 50px 0; +} +.meal { + position: relative; + background: #fff; + box-shadow: 0 3px 4px rgba(0,0,0,.1); + border: 1px solid #c1cedb; + border-radius: 3px; + overflow: hidden; + h1 { + flex-grow: 1; + display: flex; + align-items: center; + margin: 0; + padding: 0; + font-size: 24px; + img { + margin: 0 10px 0 0; + } + } + &__title { + display: flex; + align-items: center; + padding: 30px; + background: #f6fafd; + border-bottom: 1px solid #c1cedb; + } +} +.message { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 30px; + font-size: 22px; + font-weight: 500; + img { + margin: 0 10px 0 0; + } +} \ No newline at end of file diff --git a/src/health/meals/containers/meal/meal.component.ts b/src/health/meals/containers/meal/meal.component.ts new file mode 100644 index 00000000..15356676 --- /dev/null +++ b/src/health/meals/containers/meal/meal.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; + +import { Observable } from 'rxjs/Observable'; +import { Subscription } from 'rxjs/Subscription'; +import 'rxjs/add/operator/switchMap'; + +import { MealsService, Meal } from '../../../shared/services/meals/meals.service'; + +@Component({ + selector: 'meal', + styleUrls: ['meal.component.scss'], + template: ` +
+
+

+ + + {{ meal.name ? 'Edit' : 'Create' }} meal + + + Loading... + +

+
+
+ + +
+ +
+ + Fetching meal... +
+
+
+ ` +}) +export class MealComponent implements OnInit, OnDestroy { + + meal$: Observable; + subscription: Subscription; + + constructor( + private mealsService: MealsService, + private router: Router, + private route: ActivatedRoute + ) {} + + ngOnInit() { + this.subscription = this.mealsService.meals$.subscribe(); + this.meal$ = this.route.params + .switchMap(param => this.mealsService.getMeal(param.id)); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } + + async addMeal(event: Meal) { + await this.mealsService.addMeal(event); + this.backToMeals(); + } + + async updateMeal(event: Meal) { + const key = this.route.snapshot.params.id; + await this.mealsService.updateMeal(key, event); + this.backToMeals(); + } + + async removeMeal(event: Meal) { + const key = this.route.snapshot.params.id; + await this.mealsService.removeMeal(key); + this.backToMeals(); + } + + backToMeals() { + this.router.navigate(['meals']); + } + +} \ No newline at end of file diff --git a/src/health/meals/containers/meals/meals.component.scss b/src/health/meals/containers/meals/meals.component.scss new file mode 100644 index 00000000..c8d4b2d1 --- /dev/null +++ b/src/health/meals/containers/meals/meals.component.scss @@ -0,0 +1,59 @@ +:host { + display: block; + margin: 50px 0; +} +.meals { + position: relative; + background: #fff; + box-shadow: 0 3px 4px rgba(0,0,0,.1); + border: 1px solid #c1cedb; + border-radius: 3px; + overflow: hidden; + h1 { + flex-grow: 1; + display: flex; + align-items: center; + margin: 0; + padding: 0; + font-size: 24px; + img { + margin: 0 10px 0 0; + } + } + &__title { + display: flex; + align-items: center; + padding: 30px; + background: #f6fafd; + border-bottom: 1px solid #c1cedb; + } +} +.btn__add { + display: flex; + align-items: center; + color: #fff; + background: #97c747; + border-radius: 50px; + padding: 6px 20px 6px 15px; + text-transform: uppercase; + font: { + weight: 600; + size: 13px; + } + img { + width: 20px; + margin: 0 6px 0 0; + } +} +.message { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 30px; + font-size: 22px; + font-weight: 500; + img { + margin: 0 10px 0 0; + } +} \ No newline at end of file diff --git a/src/health/meals/containers/meals/meals.component.ts b/src/health/meals/containers/meals/meals.component.ts new file mode 100644 index 00000000..5cf7adec --- /dev/null +++ b/src/health/meals/containers/meals/meals.component.ts @@ -0,0 +1,70 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; + +import { Store } from 'store'; + +import { Observable } from 'rxjs/Observable'; +import { Subscription } from 'rxjs/Subscription'; + +import { Meal, MealsService } from '../../../shared/services/meals/meals.service'; + +@Component({ + selector: 'meals', + styleUrls: ['meals.component.scss'], + template: ` +
+
+

+ + Your meals +

+ + + New meal + +
+
+
+ + No meals, add a new meal to start +
+ + +
+ +
+ + Fetching meals... +
+
+
+ ` +}) +export class MealsComponent implements OnInit, OnDestroy { + + meals$: Observable; + subscription: Subscription; + + constructor( + private store: Store, + private mealsService: MealsService + ) {} + + ngOnInit() { + this.meals$ = this.store.select('meals'); + this.subscription = this.mealsService.meals$.subscribe(); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } + + removeMeal(event: Meal) { + this.mealsService.removeMeal(event.$key); + } + +} \ No newline at end of file diff --git a/src/health/meals/meals.module.ts b/src/health/meals/meals.module.ts new file mode 100644 index 00000000..91f469fc --- /dev/null +++ b/src/health/meals/meals.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterModule, Routes } from '@angular/router'; + +import { SharedModule } from '../shared/shared.module'; + +// components +import { MealFormComponent } from './components/meal-form/meal-form.component'; + +// containers +import { MealsComponent } from './containers/meals/meals.component'; +import { MealComponent } from './containers/meal/meal.component'; + +export const ROUTES: Routes = [ + { path: '', component: MealsComponent }, + { path: 'new', component: MealComponent }, + { path: ':id', component: MealComponent }, +]; + +@NgModule({ + imports: [ + CommonModule, + ReactiveFormsModule, + RouterModule.forChild(ROUTES), + SharedModule + ], + declarations: [ + MealsComponent, + MealComponent, + MealFormComponent + ] +}) +export class MealsModule {} \ No newline at end of file diff --git a/src/health/schedule/containers/schedule/schedule.component.scss b/src/health/schedule/containers/schedule/schedule.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/health/schedule/containers/schedule/schedule.component.ts b/src/health/schedule/containers/schedule/schedule.component.ts new file mode 100644 index 00000000..01c534a8 --- /dev/null +++ b/src/health/schedule/containers/schedule/schedule.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'schedule', + styleUrls: ['schedule.component.scss'], + template: ` +
+ Schedule +
+ ` +}) +export class ScheduleComponent { + constructor() {} +} \ No newline at end of file diff --git a/src/health/schedule/schedule.module.ts b/src/health/schedule/schedule.module.ts new file mode 100644 index 00000000..6c7e307b --- /dev/null +++ b/src/health/schedule/schedule.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterModule, Routes } from '@angular/router'; + +// containers +import { ScheduleComponent } from './containers/schedule/schedule.component'; + +export const ROUTES: Routes = [ + { path: '', component: ScheduleComponent } +]; + +@NgModule({ + imports: [ + CommonModule, + ReactiveFormsModule, + RouterModule.forChild(ROUTES) + ], + declarations: [ + ScheduleComponent + ] +}) +export class ScheduleModule {} \ No newline at end of file diff --git a/src/health/shared/components/list-item/list-item.component.scss b/src/health/shared/components/list-item/list-item.component.scss new file mode 100644 index 00000000..e060d834 --- /dev/null +++ b/src/health/shared/components/list-item/list-item.component.scss @@ -0,0 +1,72 @@ +.list-item { + display: flex; + border-bottom: 1px solid #c1cedb; + transition: all .2s ease-in-out; + &:hover { + background-color: #f9f9f9; + } + p { + margin: 0; + } + &__name { + flex-grow: 1; + } + &__ingredients { + font-size: 12px; + color: #8ea6bd; + font-style: italic; + } + &__delete { + display: flex; + align-items: center; + margin: 0 10px 0 0; + p { + margin: 0 10px 0 0; + font-size: 14px; + } + } + a { + display: flex; + flex-grow: 1; + flex-direction: column; + height: 100%; + padding: 12px 20px; + font-weight: 400; + color: #545e6f; + font-size: 16px; + } +} +%button { + outline: 0; + cursor: pointer; + border: 0; +} +.confirm, +.cancel { + @extend %button; + padding: 5px 10px; + margin: 0 0 0 5px; + font-size: 14px; +} +.confirm { + color: #fff; + background: #d73a49; + border-radius: 3px; + transition: all .2s ease-in-out; + &:hover { + background: darken(#d73a49, 3%); + } +} +.cancel { + background: transparent; +} +.trash { + @extend %button; + border-left: 1px solid #c1cedb; + padding: 10px 15px; + background: #f6fafd; + transition: all .2s ease-in-out; + &:hover { + background-color: darken(#f6fafd, 2%); + } +} \ No newline at end of file diff --git a/src/health/shared/components/list-item/list-item.component.ts b/src/health/shared/components/list-item/list-item.component.ts new file mode 100644 index 00000000..cad622ae --- /dev/null +++ b/src/health/shared/components/list-item/list-item.component.ts @@ -0,0 +1,71 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'list-item', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['list-item.component.scss'], + template: ` +
+ + +

{{ item.name }}

+

+ + {{ item.ingredients }} + +

+ +
+ +
+

Delete item?

+ + +
+ + + +
+ ` +}) +export class ListItemComponent { + + toggled = false; + + @Input() + item: any; + + @Output() + remove = new EventEmitter(); + + constructor() {} + + toggle() { + this.toggled = !this.toggled; + } + + removeItem() { + this.remove.emit(this.item); + } + + getRoute(item: any) { + return [`../meals`, item.$key]; + } +} \ No newline at end of file diff --git a/src/health/shared/services/meals/meals.service.ts b/src/health/shared/services/meals/meals.service.ts new file mode 100644 index 00000000..f9209fdf --- /dev/null +++ b/src/health/shared/services/meals/meals.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { AngularFireDatabase } from 'angularfire2/database'; + +import { Store } from 'store'; + +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/operator/do'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/map'; +import 'rxjs/add/observable/of'; + +import { AuthService } from '../../../../auth/shared/services/auth/auth.service'; + +export interface Meal { + name: string, + ingredients: string[], + timestamp: number, + $key: string, + $exists: () => boolean +} + +@Injectable() +export class MealsService { + + meals$: Observable = this.db.list(`meals/${this.uid}`) + .do(next => this.store.set('meals', next)); + + constructor( + private store: Store, + private db: AngularFireDatabase, + private authService: AuthService + ) {} + + get uid() { + return this.authService.user.uid; + } + + getMeal(key: string) { + if (!key) return Observable.of({}); + return this.store.select('meals') + .filter(Boolean) + .map(meals => meals.find((meal: Meal) => meal.$key === key)); + } + + addMeal(meal: Meal) { + return this.db.list(`meals/${this.uid}`).push(meal); + } + + updateMeal(key: string, meal: Meal) { + return this.db.object(`meals/${this.uid}/${key}`).update(meal); + } + + removeMeal(key: string) { + return this.db.list(`meals/${this.uid}`).remove(key); + } + +} \ No newline at end of file diff --git a/src/health/shared/shared.module.ts b/src/health/shared/shared.module.ts new file mode 100644 index 00000000..315342b6 --- /dev/null +++ b/src/health/shared/shared.module.ts @@ -0,0 +1,36 @@ +import { NgModule, ModuleWithProviders } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; + +// third-party modules +import { AngularFireDatabaseModule } from 'angularfire2/database'; + +// components +import { ListItemComponent } from './components/list-item/list-item.component'; + +// services +import { MealsService } from './services/meals/meals.service'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + AngularFireDatabaseModule + ], + declarations: [ + ListItemComponent + ], + exports: [ + ListItemComponent + ] +}) +export class SharedModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: SharedModule, + providers: [ + MealsService + ] + }; + } +} \ No newline at end of file diff --git a/src/health/workouts/containers/workouts/workouts.component.scss b/src/health/workouts/containers/workouts/workouts.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/health/workouts/containers/workouts/workouts.component.ts b/src/health/workouts/containers/workouts/workouts.component.ts new file mode 100644 index 00000000..beb52145 --- /dev/null +++ b/src/health/workouts/containers/workouts/workouts.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'workouts', + styleUrls: ['workouts.component.scss'], + template: ` +
+ Workouts +
+ ` +}) +export class WorkoutsComponent { + constructor() {} +} \ No newline at end of file diff --git a/src/health/workouts/workouts.module.ts b/src/health/workouts/workouts.module.ts new file mode 100644 index 00000000..6ca6121a --- /dev/null +++ b/src/health/workouts/workouts.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterModule, Routes } from '@angular/router'; + +// containers +import { WorkoutsComponent } from './containers/workouts/workouts.component'; + +export const ROUTES: Routes = [ + { path: '', component: WorkoutsComponent } +]; + +@NgModule({ + imports: [ + CommonModule, + ReactiveFormsModule, + RouterModule.forChild(ROUTES) + ], + declarations: [ + WorkoutsComponent + ] +}) +export class WorkoutsModule {} \ No newline at end of file diff --git a/src/store.ts b/src/store.ts index 7147daf3..cb6a2162 100644 --- a/src/store.ts +++ b/src/store.ts @@ -4,11 +4,19 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import 'rxjs/add/operator/pluck'; import 'rxjs/add/operator/distinctUntilChanged'; +import { User } from './auth/shared/services/auth/auth.service'; +import { Meal } from './health/shared/services/meals/meals.service'; + export interface State { + user: User, + meals: Meal[], [key: string]: any } -const state: State = {}; +const state: State = { + user: undefined, + meals: undefined, +}; export class Store {