Handling late feature requirements – managing compositions

It's time to deal with unexpected traffic along Route 66. We have encountered a late feature requirement, realizing we need a way to manage any number of different mixes so we can work on different material over time. We could refer to each mix as a composition of audio tracks.

The good news is we have spent a reasonable amount of time engineering a scalable architecture and we are about to reap the fruits of our labor. Responding to late feature requirements now becomes a rather enjoyable Sunday stroll around the neighborhood. Let's show off the strengths of our app's architecture by taking a moment to work on this new feature.

Let's start by defining a new route for a new MixListComponent we will create. Open app/modules/mixer/mixer.module.ts and make the following highlighted modifications:

...
import { MixListComponent } from './components/mix-list.component';
import { PROVIDERS } from './services';


const COMPONENTS: any[] = [
BaseComponent,
MixerComponent,
MixListComponent
]

const routes: Routes = [
{
path: '',
component: BaseComponent,
children: [
{
path: 'home',
component: MixListComponent
},
{
path: ':id',
component: MixerComponent
}
]
}
];

@NgModule({
...
providers: [
...PROVIDERS
]
})
export class MixerModule { }

We are switching up our initial strategy of presenting MixerComponent as the home start page, but instead we are going to create a new MixListComponent in a moment to represent the 'home' start page, which will be a listing of all the compositions we are working on. We could still have the MixListComponent auto select the last selected composition on the app launch for convenience later. We have now defined MixerComponent as a parameterized route, since it will always represent one of our working compositions identified by the ':id' param routes, which will resolve to a route looking like '/mixer/1' for example. We have also imported PROVIDERS, which we will create in a moment.

Let's modify DatabaseService provided by CoreModule to help provide a constant persistence key for our new data needs. We will want to persist user created compositions stored via this constant key name. Open app/modules/core/services/database.service.ts and make the following highlighted modifications:

...
interface IKeys {
currentUser: string;
compositions: string;
}

@Injectable()
export class DatabaseService {

public static KEYS: IKeys = {
currentUser: 'current-user',
compositions: 'compositions'
};
...

Let's also create a new data model to represent our compositions. Create app/modules/shared/models/composition.model.ts:

import { ITrack } from './track.model';

export interface IComposition {
id: number;
name: string;
created: number;
tracks: Array<ITrack>;
order: number;
}
export class CompositionModel implements IComposition {
public id: number;
public name: string;
public created: number;
public tracks: Array<ITrack> = [];
public order: number;

constructor(model?: any) {
if (model) {
for (let key in model) {
this[key] = model[key];
}
}
if (!this.created) this.created = Date.now();
// if not assigned, just assign a random id
if (!this.id)
this.id = Math.floor(Math.random() * 100000);
}
}

Then, holding strong to our conventions, open app/modules/shared/models/index.ts and re-export this new model:

export * from './composition.model';
export * from './track.model';

We can now use this new model and database key in a new data service on which to build this new feature. Create app/modules/mixer/services/mixer.service.ts:

// angular
import { Injectable } from '@angular/core';

// app
import { ITrack, IComposition, CompositionModel } from '../../shared/models';
import { DatabaseService } from '../../core/services/database.service';
import { DialogService } from '../../core/services/dialog.service';

@Injectable()
export class MixerService {

public list: Array<IComposition>;

constructor(
private databaseService: DatabaseService,
private dialogService: DialogService
) {
// restore with saved compositions or demo list
this.list = this._savedCompositions() ||
this._demoComposition();
}

public add() {
this.dialogService.prompt('Composition name:')
.then((value) => {
if (value.result) {
let composition = new CompositionModel({
id: this.list.length + 1,
name: value.text,
order: this.list.length // next one in line
});
this.list.push(composition);
// persist changes
this._saveList();
}
});
}

public edit(composition: IComposition) {
this.dialogService.prompt('Edit name:', composition.name)
.then((value) => {
if (value.result) {
for (let comp of this.list) {
if (comp.id === composition.id) {
comp.name = value.text;
break;
}
}
// re-assignment triggers view binding change
// only needed with default change detection
// when object prop changes in collection
// NOTE: we will use Observables in ngrx chapter
this.list = [...this.list];
// persist changes
this._saveList();
}
});
}

private _savedCompositions(): any {
return this.databaseService
.getItem(DatabaseService.KEYS.compositions);
}

private _saveList() {
this.databaseService
.setItem(DatabaseService.KEYS.compositions, this.list);
}

private _demoComposition(): Array<IComposition> {
// Starter composition to demo on first launch
return [
{
id: 1,
name: 'Demo',
created: Date.now(),
order: 0,
tracks: [
{
id: 1,
name: 'Guitar',
order: 0
},
{
id: 2,
name: 'Vocals',
order: 1
}
]
}
]
}
}

We now have a service that will provide a list to bind our view to display the user's saved compositions. It also provides a way to add and edit compositions and seed the first app launch with a demo composition for a good first-time user experience (we will add actual tracks to the demo later).

In keeping with our conventions, let's also add app/modules/mixer/services/index.ts, as follows, which we illustrated being imported in MixerModule a moment ago:

import { MixerService } from './mixer.service';

export const PROVIDERS: any[] = [
MixerService
];

export * from './mixer.service';

Let's now create app/modules/mixer/components/mix-list.component.ts to consume and project our new data service:

// angular
import { Component } from '@angular/core';

// app
import { MixerService } from '../services/mixer.service';

@Component({
moduleId: module.id,
selector: 'mix-list',
templateUrl: 'mix-list.component.html'
})
export class MixListComponent {

constructor(public mixerService: MixerService) { }
}

And, for the view template, app/modules/mixer/components/mix-list.component.html:

<ActionBar title="Compositions" class="action-bar">
<ActionItem (tap)="mixerService.add()"
ios.position="right">
<Button text="New" class="action-item"></Button>
</ActionItem>
</ActionBar>
<ListView [items]="mixerService.list | orderBy: 'order'"
class="list-group">
<ng-template let-composition="item">
<GridLayout rows="auto" columns="100,*,auto"
class="list-group-item">
<Button text="Edit" row="0" col="0"
(tap)="mixerService.edit(composition)"></Button>
<Label [text]="composition.name"
[nsRouterLink]="['/mixer', composition.id]"
class="h2" row="0" col="1"></Label>
<Label [text]="composition.tracks.length"
class="text-right" row="0" col="2"></Label>
</GridLayout>
</ng-template>
</ListView>

This will render our list of MixerService user-saved compositions to the view and, when we first launch the app, it will have been seeded with one sample Demo composition preloaded with two recordings, so the user can play around. Here is how things look on iOS upon first launch now:

We can create new compositions and edit the names of existing ones. We can also tap the composition's name to view  MixerComponent; however, we need to adjust the component to grab the route ':id' param and wire its view into the selected composition. Open app/modules/mixer/components/mixer.component.ts and add the highlighted sections:

// angular
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs/Subscription';

// app
import { MixerService } from '../services/mixer.service';
import { CompositionModel } from '../../shared/models';

@Component({
moduleId: module.id,
selector: 'mixer',
templateUrl: 'mixer.component.html'
})
export class MixerComponent implements OnInit, OnDestroy {

public composition: CompositionModel;
private _sub: Subscription;

constructor(
private route: ActivatedRoute,
private mixerService: MixerService
) { }

ngOnInit() {
this._sub = this.route.params.subscribe(params => {
for (let comp of this.mixerService.list) {
if (comp.id === +params['id']) {
this.composition = comp;
break;
}
}
});
}

ngOnDestroy() {
this._sub.unsubscribe();
}
}

We can inject Angular's ActivatedRoute to subscribe to the route's params, which give us access to id. Because it will come in as a String by default, we use +params['id'] to convert it to a number when we locate the composition in our service's list. We assign a local reference to the selected composition, which now allows us to bind to it in the view. While we're at it, we will also add a Button labeled List for now in ActionBar to navigate back to our compositions (later, we will implement font icons to display in their place). Open app/modules/mixer/components/mixer.component.html and make the following highlighted modifications:

<ActionBar [title]="composition.name" class="action-bar">
<ActionItem nsRouterLink="/mixer/home">
<Button text="List" class="action-item"></Button>
</ActionItem>
<ActionItem nsRouterLink="/record" ios.position="right">
<Button text="Record" class="action-item"></Button>
</ActionItem>
</ActionBar>
<GridLayout rows="*, 100" columns="*" class="page">
<track-list [tracks]="composition.tracks" row="0" col="0"></track-list>
<player-controls row="1" col="0"></player-controls>
</GridLayout>

This allows us to display the selected composition's name in the title of ActionBar as well as pass its tracks to track-list. We need to add Input to track-list, so it renders the composition's tracks instead of the dummy data it's bound to now. Let's open app/modules/player/components/track-list/track-list.component.ts and add an Input:

...
export class TrackListComponent {

@Input() tracks: Array<ITrack>;

...
}

Previously, the TrackListComponent view was bound to playerService.tracks, so let's adjust the view template for the component at app/modules/player/components/track-list/track-list.component.html to bind to our new Input, which will now represent the tracks in the user's actual selected composition:

<ListView [items]="tracks | orderBy: 'order'" class="list-group">
<template let-track="item">
<GridLayout rows="auto" columns="100,*,100" class="list-group-item">
<Button text="Record" (tap)="record(track)" row="0" col="0" class="c-ruby"></Button>
<Label [text]="track.name" row="0" col="1" class="h2"></Label>
<Switch [checked]="track.solo" row="0" col="2" class="switch"></Switch>
</GridLayout>
</template>
</ListView>

We now have the following sequence in our app to meet the needs of this late feature requirement and we did it in just a few pages of material here:

And it works exactly the same on Android while retaining its unique native characteristics. 

You might notice, however, that ActionBar on Android defaults to all ActionItem on the right-hand side. One last trick we want to show you quickly is the ability for platform-specific view templates. Oh and don't worry about those ugly Android buttons; we will integrate font icons later for those.

Create platform-specific view templates wherever you see fit. Doing so will help you dial views for each platform where necessary and make them highly maintainable.

Let's create app/modules/mixer/components/action-bar/action-bar.component.ts:

// angular
import { Component, Input } from '@angular/core';

@Component({
moduleId: module.id,
selector: 'action-bar',
templateUrl: 'action-bar.component.html'
})
export class ActionBarComponent {

@Input() title: string;
}

You can then create an iOS-specific view template: app/modules/mixer/components/action-bar/action-bar.component.ios.html:

<ActionBar [title]="title" class="action-bar">
<ActionItem nsRouterLink="/mixer/home">
<Button text="List" class="action-item"></Button>
</ActionItem>
<ActionItem nsRouterLink="/record" ios.position="right">
<Button text="Record" class="action-item"></Button>
</ActionItem>
</ActionBar>

And an Android-specific view template: app/modules/mixer/components/action-bar/action-bar.component.android.html:

<ActionBar class="action-bar">
<GridLayout rows="auto" columns="auto,*,auto" class="action-bar">
<Button text="List" nsRouterLink="/mixer/home" class="action-item" row="0" col="0"></Button>
<Label [text]="title" class="action-bar-title text-center" row="0" col="1"></Label>
<Button text="Record" nsRouterLink="/record" class="action-item" row="0" col="2"></Button>
</GridLayout>
</ActionBar>

Then we can use it in app/modules/mixer/components/mixer.component.html:

<action-bar [title]="composition.name"></action-bar>
<GridLayout rows="*, 100" columns="*" class="page">
<track-list [tracks]="composition.tracks" row="0" col="0"></track-list>
<player-controls row="1" col="0"></player-controls>
</GridLayout>

Just ensure you add it to the COMPONENTS of MixerModule  in app/modules/mixer/mixer.module.ts:

...
import { ActionBarComponent } from './components/action-bar/action-bar.component';
...

const COMPONENTS: any[] = [
ActionBarComponent,
BaseComponent,
MixerComponent,
MixListComponent
];
...

Voila! 

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.188.26.158