Working on my first "big" project using the mighty Xamarin. Likewise my first venture in XAML so I suppose most focus will be going towards these new areas. Performance- and user-experience related remarks are welcome as well though. Or you know, just any issue you can see.
The solution consists of 3 actively used projects (the 4th - MoviePicker.Android - doesn't contain any platform-specific code). There's the shared Xamarin.Forms project, a ASP.NET Web Api 2 project and a PCL to share models between the API and the app. I will omit the PCL since it's just a few models with JSON annotations.
Web API
WebApiConfig
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
// Web API configuration and services
// Web API routes
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
name: "test",
routeTemplate: "api/{controller}/{action}/{id}",
defaults: new { id = RouteParameter.Optional }
);
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new {id = RouteParameter.Optional}
);
}
}
BaseController
public class BaseController : ApiController
{
protected const string ApiKey = "Will_Move_To_AppSettings";
protected TMDbClient Client = new TMDbClient(ApiKey);
}
MovieController
namespace MoviePickerApi.Controllers
{
[RoutePrefix("api/movies")]
public class MovieController : BaseController
{
public MovieController()
{
Client.GetConfig();
}
[Route("~/api/genres")]
[HttpGet]
[ResponseType(typeof(IEnumerable<Genre>))]
public IHttpActionResult GetGenres()
{
return Ok(Client.GetGenres());
}
[Route("~/api/genres/{id:int}")]
[HttpGet]
[ResponseType(typeof(IEnumerable<MovieResult>))]
public IHttpActionResult GetMoviesForGenre(int id)
{
var results = Client.GetGenreMovies(id).Results;
foreach (var genre in results)
{
genre.PosterPath = Client.GetImageUrl("original", genre.PosterPath).ToString();
}
return Ok(results);
}
[Route("{id:int}")]
[HttpGet]
[ResponseType(typeof(Movie))]
public IHttpActionResult GetMovieDetails(int id)
{
var movie = Client.GetMovie(id);
movie.PosterPath = Client.GetImageUrl("original", movie.PosterPath).ToString();
return Ok(movie);
}
}
}
UserController
namespace MoviePickerApi.Controllers
{
[RoutePrefix("api/users")]
public class UserController : BaseController
{
private readonly MoviePickerContext _db = new MoviePickerContext();
[Route("{id:int}")]
[HttpGet]
[ResponseType(typeof(User))]
public async Task<IHttpActionResult> GetUser(int? id)
{
var user = await _db.Users.FindAsync(id);
if (user == null)
{
return NotFound();
}
return Ok(user);
}
[Route("create")]
[HttpPost]
[ResponseType(typeof(User))]
public async Task<IHttpActionResult> CreateUser([FromBody] User user)
{
if (UserExists(user.Email))
{
return Conflict();
}
_db.Users.Add(user);
await _db.SaveChangesAsync();
user.Password = string.Empty;
return Ok(user);
}
[Route("login")]
[HttpPost]
[ResponseType(typeof(User))]
public async Task<IHttpActionResult> Login([FromBody] User user)
{
var dbUser = await _db.Users.FirstOrDefaultAsync(x => x.Email == user.Email);
if (dbUser == null || dbUser.Password != user.Password)
{
return NotFound();
}
return Ok(dbUser);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_db.Dispose();
}
base.Dispose(disposing);
}
private bool UserExists(string email)
{
return _db.Users.FirstOrDefault(e => e.Email == email) != null;
}
}
}
Xamarin.Forms
DataSource
public class DataSource
{
private readonly HttpClient _client;
private const string BaseUrl = "http://mpdev.azurewebsites.net/api";
public DataSource()
{
_client = new HttpClient();
_client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task<IEnumerable<Genre>> GetGenresAsync()
{
var data = await _client.GetStringAsync(BaseUrl + "/genres");
return JsonConvert.DeserializeObject<List<Genre>>(data);
}
public async Task<IEnumerable<MovieSearchResult>> GetMoviesForGenreAsync(int genreId)
{
var data = await _client.GetStringAsync(BaseUrl + "/genres/" + genreId);
return JsonConvert.DeserializeObject<List<MovieSearchResult>>(data);
}
public async Task<Movie> GetMovieAsync(int movieId)
{
var data = await _client.GetStringAsync(BaseUrl + "/movies/" + movieId);
return JsonConvert.DeserializeObject<Movie>(data);
}
public async Task<HttpResponseMessage> RegisterAsync(string email, string password, string firstname, string lastname)
{
var content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("email", email),
new KeyValuePair<string, string>("password", password),
new KeyValuePair<string, string>("firstname", firstname),
new KeyValuePair<string, string>("lastname", lastname),
});
return await _client.PostAsync(BaseUrl + "/users/create", content);
}
}
Session
public class Session
{
public User User { get; set; }
private readonly DataSource _dataSource = new DataSource();
public DataSource DataSource
{
get { return _dataSource; }
}
public string DatabasePath
{
get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "database.db3"); }
}
public bool IsLoggedIn
{
get { return User != null; }
}
}
App
public class App
{
public static Page GetMainPage()
{
var session = new Session();
return new NavigationPage(new WelcomePage(session));
}
}
WelcomePage
<ScrollView>
<StackLayout Orientation="Vertical">
<Label Text="Welcome!" HorizontalOptions="CenterAndExpand" Font="Bold, Small" />
<StackLayout Orientation="Vertical">
<Button Text="Login" HorizontalOptions="CenterAndExpand" VerticalOptions="Center" Clicked="OnLoginButtonClicked" />
<Button Text="Register" HorizontalOptions="CenterAndExpand" VerticalOptions="Center" Clicked="OnRegisterButtonClicked" />
</StackLayout>
<ActivityIndicator x:Name="_activityIndicator" Color="Aqua" IsRunning="false" HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand" />
</StackLayout>
</ScrollView>
public partial class WelcomePage : ContentPage
{
private readonly Session _session;
public WelcomePage(Session session)
{
_session = session;
InitializeComponent ();
}
private async void OnLoginButtonClicked(object sender, EventArgs e)
{
_activityIndicator.IsRunning = true;
await Navigation.PushAsync(new LoginPage(_session));
_activityIndicator.IsRunning = false;
}
private async void OnRegisterButtonClicked(object sender, EventArgs e)
{
_activityIndicator.IsRunning = true;
await Navigation.PushAsync(new RegistrationPage(_session));
_activityIndicator.IsRunning = false;
}
}
LoginPage
<ScrollView>
<StackLayout Orientation="Vertical">
<!--- Menu -->
<Label Text="Welcome!" HorizontalOptions="Center" Font="Bold, 30" />
<Entry x:Name="_emailEntry" Keyboard="Email" Placeholder="Enter your email address" HorizontalOptions="CenterAndExpand" VerticalOptions="Center" />
<Entry x:Name="_passwordEntry" Keyboard="Text" Placeholder="Enter your password" IsPassword="true" VerticalOptions="Center" HorizontalOptions="CenterAndExpand" />
<Button Text="Log me in" HorizontalOptions="CenterAndExpand" VerticalOptions="Center" Clicked="OnLoginButtonClicked" />
<ActivityIndicator x:Name="_activityIndicator" Color="Aqua" IsRunning="false" HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand" />
</StackLayout>
</ScrollView>
public partial class LoginPage : ContentPage
{
private readonly Session _session;
public LoginPage(Session session)
{
_session = session;
InitializeComponent();
}
private async void OnLoginButtonClicked(object sender, EventArgs args)
{
_activityIndicator.IsRunning = true;
var isValidTask = IsValidLogin();
var isValid = await isValidTask;
if (isValid)
{
var genres = await _session.DataSource.GetGenresAsync();
await Navigation.PushAsync(new GenrePage(_session, genres));
}
else
{
var alert = new AlertConfig
{
Message = "The entered credentials are invalid",
Title = "Invalid login",
OkText = "Okay then.."
};
var dialogService = new UserDialogService();
await dialogService.AlertAsync(alert);
}
if (isValidTask.IsCompleted)
{
_activityIndicator.IsRunning = false;
}
}
private async Task<bool> IsValidLogin()
{
var email = _emailEntry.Text;
var password = _passwordEntry.Text;
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("email", email),
new KeyValuePair<string, string>("password", password)
});
HttpResponseMessage response;
try
{
response = await client.PostAsync("http://mpdev.azurewebsites.net/api/users/login", content);
var json = await response.Content.ReadAsStringAsync();
var obj = JsonConvert.DeserializeObject<User>(json);
_session.User = obj;
}
catch (Exception e)
{
//TODO: log
var alert = new AlertConfig
{
Message = "Something went wrong with your request, try again later.",
Title = "Uh oh",
OkText = "Fk dis app"
};
var dialogService = new UserDialogService();
dialogService.AlertAsync(alert);
return false;
}
return response.IsSuccessStatusCode;
}
}
}
RegistrationPage
<ScrollView>
<StackLayout Orientation="Vertical">
<Label Text="Registration" HorizontalOptions="CenterAndExpand" Font="Bold, Small" />
<Entry x:Name="_email" Keyboard="Email" Placeholder="Enter your email address" HorizontalOptions="CenterAndExpand" VerticalOptions="Center" />
<Entry x:Name="_password" Keyboard="Text" Placeholder="Enter your password" IsPassword="true" VerticalOptions="Center" HorizontalOptions="CenterAndExpand" />
<Entry x:Name="_firstname" Keyboard="Text" Placeholder="Enter your first name" HorizontalOptions="CenterAndExpand" VerticalOptions="Center" />
<Entry x:Name="_lastname" Keyboard="Text" Placeholder="Enter your last name" HorizontalOptions="CenterAndExpand" VerticalOptions="Center" />
<Button Text="Register me" HorizontalOptions="CenterAndExpand" VerticalOptions="Center" Clicked="OnRegistrationButtonClicked" />
<ActivityIndicator x:Name="_activityIndicator" Color="Aqua" IsRunning="false" HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand" />
</StackLayout>
</ScrollView>
public partial class RegistrationPage : ContentPage
{
private readonly Session _session;
public RegistrationPage (Session session)
{
_session = session;
InitializeComponent();
}
private async void OnRegistrationButtonClicked(object sender, EventArgs e)
{
_activityIndicator.IsRunning = true;
var response = await _session.DataSource.RegisterAsync(_email.Text, _password.Text, _firstname.Text, _lastname.Text);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var obj = JsonConvert.DeserializeObject<User>(json);
_session.User = obj;
var genres = await _session.DataSource.GetGenresAsync();
await Navigation.PushAsync(new GenrePage(_session, genres));
}
else
{
if (response.StatusCode == HttpStatusCode.Conflict)
{
var alert = await DisplayAlert("User exists",
"A user with this email address already exists",
"Go to login",
"Cancel");
// User clicked on "Go to login"
if (alert)
{
await Navigation.PushAsync(new LoginPage(_session));
}
}
else
{
var alert = new AlertConfig
{
Message = "Something went wrong with your request, try again later.",
Title = "Uh oh",
OkText = "Fk dis app"
};
new UserDialogService().AlertAsync(alert);
}
}
_activityIndicator.IsRunning = false;
}
}
GenrePage
<StackLayout Orientation="Vertical">
<Label Text="Select a genre" Font="30" HorizontalOptions="Center" />
<TableView Intent="Menu">
<TableRoot>
<TableSection x:Name="_genreCells">
<!-- Fill with custom cells in code -->
</TableSection>
</TableRoot>
</TableView>
</StackLayout>
public partial class GenrePage : ContentPage
{
private readonly IEnumerable<Genre> _genres;
private readonly Session _session;
public GenrePage(Session session, IEnumerable<Genre> genres)
{
_session = session;
_genres = genres;
InitializeComponent ();
foreach (var genre in _genres)
{
_genreCells.Add(new GenreCell(genre, _session, Navigation));
}
}
}
public class GenreCell : TextCell
{
private readonly Session _session;
private readonly INavigation _navigation;
public Genre Genre { get; set; }
public GenreCell(Genre genre, Session session, INavigation navigation)
{
Genre = genre;
_session = session;
_navigation = navigation;
Text = genre.Name;
Tapped += TappedInternal;
}
private async void TappedInternal(object sender, EventArgs e)
{
var movie = (await _session.DataSource.GetMoviesForGenreAsync(Genre.Id)).Take(1).FirstOrDefault();
var movieDetails = await _session.DataSource.GetMovieAsync(movie.Id);
await _navigation.PushAsync(new MoviePage(movieDetails));
}
}
MoviePage
<ScrollView>
<StackLayout VerticalOptions="CenterAndExpand" HorizontalOptions="CenterAndExpand">
<Label x:Name="_title" Font="Bold, 30" />
<Image x:Name="_image" WidthRequest="300" HeightRequest="250" />
<Label x:Name="_description" Font="15" />
<Button Text="More information" HorizontalOptions="EndAndExpand" Clicked="OnMoreInformationClicked" />
<StackLayout Orientation="Horizontal" HorizontalOptions="CenterAndExpand" >
<Button Text="Decline" HorizontalOptions="StartAndExpand" Clicked="OnDeclineClicked" />
<Button Text="Maybe later" HorizontalOptions="CenterAndExpand" Clicked="OnMaybeClicked" />
<Button Text="Watched!" HorizontalOptions="EndAndExpand" Clicked="OnWatchedClicked" />
</StackLayout>
</StackLayout>
</ScrollView>
public partial class MoviePage : ContentPage
{
private readonly Movie _movie;
public MoviePage (Movie movie)
{
_movie = movie;
InitializeComponent();
_title.Text = string.Format ("{0}({1})", _movie.Title, _movie.ReleaseDate.HasValue ? _movie.ReleaseDate.Value.Year.ToString () : "Unknown");
_image.Source = new UriImageSource {
CachingEnabled = true,
CacheValidity = new TimeSpan(5, 0, 0, 0),
Uri = new Uri(_movie.PosterPath)
};
_description.Text = _movie.Overview;
// Main layout: Padding = new Thickness(0, 0, 0, 0)
}
private void OnMoreInformationClicked(object sender, EventArgs e) { }
private void OnDeclineClicked(object sender, EventArgs e) { }
private void OnMaybeClicked(object sender, EventArgs e) { }
private void OnWatchedClicked(object sender, EventArgs e) { }
}
if (dbUser == null || dbUser.Password != user.Password)
-- are you storing your passwords in the clear? \$\endgroup\$//TODO: encrypt
note somewhere. \$\endgroup\$