WordPress-Seite als Xamarin.Forms App – Teil 5

Vor mehr als zwei Jahren habe ich euch bereits WordPressXF vorgestellt. Mein Kollege Thomas Pentenrieder hat eine .NET Library geschrieben, um auf einen WordPress-Blog von einer .NET App zugreifen zu können. Er hat sich dann dann primär um eine  UWP-Version gekümmert, welche die WordPressPCL-Library in Action zeigt. Ich habe mich selbst um eine Xamarin.Forms Version gekümmert. Nun dachte ich mir, dass man doch einmal die Solution updaten könnte, um so die neusten Xamarin.Forms Features verwenden zu können. Da ich jetzt nicht einfacher nur das Projekt updaten wollte, habe ich mir gedacht, dass die Umsetzung in Form von mehreren Blog-Beiträgen passiert und ich euch so zeige, wie eine kleine App entsteht, welche in der Lage ist die Beiträge eines Blogs unter Android und iOS an zu zeigen. Im fünften Teil wollen wir eine Möglichkeit schaffen, dass sich der Nutzer einloggen kann, um dann einen Kommentar zu verfassen.

Ausgangslage bildet die Solution, welche im vierten Teil entwickelt worden ist. Ihr bekommt die Solution entweder im alten Beitrag oder ansonsten über den folgenden Download-Button.

Wir beginnen mit der Erstellung zweier neuer Services. Der eine Service soll und dabei helfen, eine Dialogbox anzuzeigen und der zweite Service hilft uns beim Speichern vertraulicher Daten. Daher erstellen wir zunächst das Interface IDialogService.

public interface IDialogService
{
    Task OpenSimplePlatformDialogAsync(string title, 
        string message, string accept);

    Task<bool> OpenSimplePlatformDialogAsync(string title, 
        string message, string accept, string cancel);
}

Im nächsten Schritt folgt dann die Implementierung.

public class DialogService : IDialogService
{
    public async Task OpenSimplePlatformDialogAsync(string title, 
        string message, string accept)
    {
        await Application.Current?.
                MainPage?.DisplayAlert(title, message, accept);
    }

    public async Task<bool> OpenSimplePlatformDialogAsync(string title, 
        string message, string accept, string cancel)
    {
        return await Application.Current?.
                MainPage?.DisplayAlert(title, message, accept, cancel);
    }
}

Wir verwenden zur Anzeige die Methode DisplayAlert, welche uns Xamarin.Forms glücklicherweise zur Verfügung stellt. Wir können damit zwar keine komplexen Dialoge erstellen, sondern nur einen Text anzeigen, aber für unser Szenario reicht dies völlig aus.

Im nächsten Schritt erstellen wir das Interface ISettingsService, welches uns beim Speichern von Daten unterstützt.

public interface ISettingsService
{
    Task<string> GetAsync(string key);

    Task SetAsync(string key, string value);

    void Remove(string key);
}

Wir könnten hier die Preferences-API aus dem Xamarin.Essentials Package nutzen, aber da wir vertrauliche Daten speichern wollen, nutzen wir die SecureStorage-API in der Implementierung.

public class SettingsService : ISettingsService
{
    public async Task<string> GetAsync(string key)
    {
        return await SecureStorage.GetAsync(key);
    }

    public async Task SetAsync(string key, string value)
    {
        await SecureStorage.SetAsync(key, value);
    }

    public void Remove(string key)
    {
        SecureStorage.Remove(key);
    }
}

Diese beiden Services müssen wir nun natürlich auch noch im Bootstrapper registrieren, so dass wir diese später verwenden können. Im nächsten Schritt erweitern wir den IWordPressService um vier Methoden, welche den Nutzer einloggen und ausloggen können und dann natürlich auch die Methode, die später einen Kommentar posten soll.

public interface IWordPressService
{
    Task<IEnumerable<Post>> GetLatestPostsAsync(int page = 0, 
        int perPage = 20);

    Task<List<CommentThreaded>> GetCommentsForPostAsync(int postid);

    Task<User> LoginAsync(string username, string password);
    
    void Logout();

    Task<bool> IsUserAuthenticatedAsync();

    Task<Comment> PostCommentAsync(int postId, string text, 
        int replyTo = 0);
}

Glücklicherweise stellt uns die WordPressPCL-Library die notwendigen Methoden bereit, welche wir zur Realisierung benötigen, so dass wir entsprechend auch den WordPressService aktualisieren können.

public class WordPressService : IWordPressService
{
    private readonly WordPressClient _client;

    public WordPressService()
    {
        _client = new WordPressClient(Statics.WordpressUrl);
    }

    public async Task<IEnumerable<Post>> GetLatestPostsAsync(int page = 0, 
        int perPage = 20)
    {
        try
        {
            page++;

            var posts = await _client.Posts.Query(new PostsQueryBuilder
            {
                Page = page,
                PerPage = perPage,
                Embed = true
            });

            return posts;
        }
        catch(Exception ex)
        {
            Debug.WriteLine($"{nameof(WordPressService)} | " +
                $"{nameof(GetLatestPostsAsync)} | {ex}");
        }

        return null;
    }

    public async Task<List<CommentThreaded>> GetCommentsForPostAsync(int postid)
    {
        var comments = await _client.Comments.Query(new CommentsQueryBuilder
        {
            Posts = new[] { postid },
            Page = 1,
            PerPage = 100
        });

        return ThreadedCommentsHelper.GetThreadedComments(comments);
    }

    public async Task<User> LoginAsync(string username, string password)
    {
        _client.AuthMethod = AuthMethod.JWT;
        await _client.RequestJWToken(username, password);

        var isAuthenticated = await _client.IsValidJWToken();

        if (isAuthenticated)
            return await _client.Users.GetCurrentUser();

        return null;
    }

    public void Logout()
    {
        _client.Logout();
    }

    public async Task<bool> IsUserAuthenticatedAsync()
    {
        return await _client.IsValidJWToken();
    }

    public async Task<Comment> PostCommentAsync(int postId, string text, 
        int replyTo = 0)
    {
        var comment = new Comment(postId, text);
        if (replyTo != 0)
            comment.ParentId = replyTo;

        return await _client.Comments.Create(comment);
    }
}

Nun können wir das PostsViewModel erweitern und die neuen Methoden des WordPressServices nutzen. Ebenso stellen wir Commands bereit, welche einen Kommentar posten oder auch die Account-Seite anzeigen können. Derzeit liegt die AccountPage noch nicht vor, aber die werden wir im nächsten Schritt erstellen.

public class PostsViewModel : BaseViewModel
{
    private readonly IDialogService _dialogService;
    private readonly IWordPressService _wordPressService;

    private int _currentPage = -1;

    private ObservableCollection<Post> _posts = 
            new ObservableCollection<Post>();
    public ObservableCollection<Post> Posts
    {
        get => _posts;
        set { _posts = value; OnPropertyChanged(); }
    }

    private List<CommentThreaded> _comments;
    public List<CommentThreaded> Comments
    {
        get => _comments;
        set { _comments = value; OnPropertyChanged(); }
    }

    private Post _selectedPost;
    public Post SelectedPost
    {
        get => _selectedPost;
        set { _selectedPost = value; OnPropertyChanged(); }
    }

    private bool _isIncrementalLoading;
    public bool IsIncrementalLoading
    {
        get => _isIncrementalLoading;
        set { _isIncrementalLoading = value; OnPropertyChanged(); }
    }

    private string _commentText;
    public string CommentText
    {
        get => _commentText;
        set { _commentText = value; OnPropertyChanged(); 
                PostCommentAsyncCommand.RaiseCanExecuteChange(); }
    }

    private bool _isCommenting = false;
    public bool IsCommenting
    {
        get => _isCommenting;
        set { _isCommenting = value; OnPropertyChanged(); 
                PostCommentAsyncCommand.RaiseCanExecuteChange(); }
    }


    private AsyncRelayCommand _loadPostsAsyncCommand;
    public AsyncRelayCommand LoadPostsAsyncCommand => 
            _loadPostsAsyncCommand 
            ?? (_loadPostsAsyncCommand = new AsyncRelayCommand(LoadPostsAsync));

    private AsyncRelayCommand _loadMorePostsAsyncCommand;
    public AsyncRelayCommand LoadMorePostsAsyncCommand => 
            _loadMorePostsAsyncCommand 
            ?? (_loadMorePostsAsyncCommand = new AsyncRelayCommand(LoadMorePostsAsync));

    private AsyncRelayCommand<Post> _setSelectedPostAsyncCommand;
    public AsyncRelayCommand<Post> SetSelectedPostAsyncCommand => 
            _setSelectedPostAsyncCommand 
            ?? (_setSelectedPostAsyncCommand = new AsyncRelayCommand<Post>(SetSelectedPostASync));

    private AsyncRelayCommand _postCommentAsyncCommand;
    public AsyncRelayCommand PostCommentAsyncCommand => 
            _postCommentAsyncCommand 
            ?? (_postCommentAsyncCommand = new AsyncRelayCommand(PostCommentAsync, CanPostComment));

    private AsyncRelayCommand _showAccountCommand;
    public AsyncRelayCommand ShowAccountCommand => 
            _showAccountCommand 
            ?? (_showAccountCommand = new AsyncRelayCommand(async () => await ShowAccountAsync()));


    public PostsViewModel(IDialogService dialogService, IWordPressService wordPressService)
    {
        _dialogService = dialogService;
        _wordPressService = wordPressService;
    }


    private async Task LoadPostsAsync()
    {
        try
        {
            IsRefreshing = true;

            _currentPage = 0;

            Posts.Clear();

            var posts = await _wordPressService.GetLatestPostsAsync(_currentPage, 
                Statics.PageSize);

            Posts.AddRange(posts);
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"{nameof(PostsViewModel)} | " +
                $"{nameof(LoadPostsAsync)} | {ex}");
        }
        finally
        {
            IsRefreshing = false;
        }
    }

    private async Task LoadMorePostsAsync()
    {
        if (IsIncrementalLoading)
            return;

        try
        {
            IsIncrementalLoading = true;

            _currentPage++;

            var posts = await _wordPressService.GetLatestPostsAsync(_currentPage, 
                Statics.PageSize);

            if (posts == null)
                return;

            Posts.AddRange(posts);
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"{nameof(PostsViewModel)} | " +
                $"{nameof(LoadMorePostsAsync)} | {ex}");
        }
        finally
        {
            IsIncrementalLoading = false;
        }
    }

    private async Task SetSelectedPostASync(Post selectedPost)
    {
        try
        {
            IsLoading = true;

            Comments = null;
            CommentText = null;

            SelectedPost = selectedPost;
            await NavigationService.NavigateToAsync(NavigationTarget.PostDetailOverviewPage);

            await GetCommentsAsync(selectedPost.Id);
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"{nameof(PostsViewModel)} | " +
                $"{nameof(SetSelectedPostASync)} | {ex}");
        }
        finally
        {
            IsLoading = false;
        }
    }

    private async Task PostCommentAsync()
    {
        try
        {
            IsCommenting = true;

            if (await _wordPressService.IsUserAuthenticatedAsync())
            {
                var comment = await _wordPressService.PostCommentAsync(SelectedPost.Id, 
                    CommentText);
                if (comment != null)
                {
                    CommentText = null;
                    await GetCommentsAsync(SelectedPost.Id);
                }
            }
            else
            {
                await _dialogService.
                        OpenSimplePlatformDialogAsync(AppResources.CommentDialogNotAuthorizedTitle,
                        AppResources.CommentDialogNotAuthorizedMessage, 
                        AppResources.DialogOk);
            }
        }
        finally
        {
            IsCommenting = false;
        }
    }

    private async Task ShowAccountAsync()
    {
        await NavigationService.NavigateToAsync(NavigationTarget.AccountPage);
    }

    private async Task GetCommentsAsync(int id)
    {
        IsLoading = true;

        Comments = await _wordPressService.GetCommentsForPostAsync(id);

        IsLoading = false;
    }

    private bool CanPostComment()
    {
        return !string.IsNullOrEmpty(CommentText) && !IsCommenting;
    }
}

Wir wollen nun einen weiteren Converter schreiben, welcher überprüft, ob ein übergebenes Objekt null ist oder nicht. Diesen Converter werden wir im weiteren Verlauf verwenden, um zu entscheiden, ob der User eingeloggt ist oder nicht, um entsprechend die UI anzupassen.

public class NullToIsVisibleConverter : IValueConverter
{
    public object Convert(object value, Type targetType, 
        object parameter, CultureInfo culture)
    {
        if (parameter == null)
            return value == null;

        return value != null;
    }

    public object ConvertBack(object value, Type targetType, 
        object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

Nun wollen wir die AccountPage erstellen. Diese besteht aus zwei Komponenten. Sofern der Nutzer noch nicht eingeloggt ist, wird eine Anmeldemaske angezeigt und sollte der Nutzer bereits eingeloggt sein, so zeigen wir den hinterlegten WordPress-Namen an und eine Option zum Ausloggen.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:markupextensions="clr-namespace:XFWordPress.MarkupExtensions"
             x:Class="XFWordPress.Views.AccountPage"
             Title="{markupextensions:Translate AccountPageTitle}"
             Padding="16">

    <ContentPage.Content>
        <Grid>
            <Grid RowDefinitions="*,Auto"
                  IsVisible="{Binding CurrentUser, 
                Converter={StaticResource NullToIsVisibleConverter}}">

                <StackLayout Grid.Row="0">
                    <Entry Placeholder="{markupextensions:Translate 
                        AccountPageUsernameLabelPlaceholder}"
                           Text="{Binding Username}" />
                    <Entry Placeholder="{markupextensions:Translate 
                        AccountPagePasswordLabelPlaceholder}"
                           Text="{Binding Password}"
                           IsPassword="True" />
                </StackLayout>

                <Button Text="{markupextensions:Translate 
                    AccountPageLoginButton}"
                        Command="{Binding LoginCommand}"
                        Grid.Row="1" />
            </Grid>

            <Grid RowDefinitions="*,Auto"
                  IsVisible="{Binding CurrentUser, 
                Converter={StaticResource NullToIsVisibleConverter}, 
                ConverterParameter=reverse}">
                <StackLayout Grid.Row="0">
                    <Label Text="{markupextensions:Translate 
                        AccountPageCurrentUserLabel}"
                           FontAttributes="Bold" />
                    <Label Text="{Binding CurrentUser.Name}" />
                </StackLayout>

                <Button Text="{markupextensions:Translate 
                    AccountPageLogoutButton}"
                        Command="{Binding LogoutCommand}"
                        Grid.Row="1" />
            </Grid>

            <ActivityIndicator IsVisible="{Binding IsCurrentlyLoggingIn}"
                               IsRunning="{Binding IsCurrentlyLoggingIn}" />
        </Grid>
    </ContentPage.Content>

</ContentPage>

Damit wir unsere AccountPage mit Leben füllen können, erstellen wir das AccountViewModel, welches sich darum kümmert einen Login- und Logout-Command zur Verfügung zu stellen und bietet auch eine Option zum automatischen Einloggen beim App-Start, sofern die notwendigen Login-Daten zur Verfügung stehen.

public class AccountViewModel : BaseViewModel
{
    private readonly ISettingsService _settingsService;
    private readonly IWordPressService _wordPressService;


    private bool _isCurrentlyLoggingIn;
    public bool IsCurrentlyLoggingIn
    {
        get => _isCurrentlyLoggingIn;
        set { _isCurrentlyLoggingIn = value; OnPropertyChanged(); }
    }

    private string _username;
    public string Username
    {
        get => _username;
        set { _username = value; OnPropertyChanged(); 
                LoginCommand.RaiseCanExecuteChange(); }
    }

    private string _password;
    public string Password
    {
        get => _password;
        set { _password = value; OnPropertyChanged(); 
                LoginCommand.RaiseCanExecuteChange(); }
    }

    private User _currentUser;
    public User CurrentUser
    {
        get => _currentUser;
        set { _currentUser = value; OnPropertyChanged(); }
    }


    private AsyncRelayCommand _tryAutoLoginCommand;
    public AsyncRelayCommand TryAutoLoginCommand => 
            _tryAutoLoginCommand 
            ?? (_tryAutoLoginCommand = new AsyncRelayCommand(async () => await TryAutoLoginAsync()));

    private AsyncRelayCommand _loginCommand;
    public AsyncRelayCommand LoginCommand => 
            _loginCommand 
            ?? (_loginCommand = new AsyncRelayCommand(async () => await LoginAsync(), CanLogin));

    private ICommand _logoutCommand;
    public ICommand LogoutCommand => 
            _logoutCommand 
            ?? (_logoutCommand = new Command(Logout));

    public AccountViewModel(ISettingsService settingsService, 
        IWordPressService wordPressService)
    {
        _settingsService = settingsService;
        _wordPressService = wordPressService;
    }


    private async Task TryAutoLoginAsync()
    {
        var username = await _settingsService
                .GetAsync(Statics.UsernameSettingsKey);
        var password = await _settingsService
                .GetAsync(Statics.PasswordSettingsKey);

        if (string.IsNullOrEmpty(username) 
                || string.IsNullOrEmpty(password))
            return;

        var user = await _wordPressService.LoginAsync(username, password);

        if (user != null)
            CurrentUser = user;
    }

    private async Task LoginAsync()
    {
        if (string.IsNullOrEmpty(Username) 
                || string.IsNullOrEmpty(Password))
            return;

        IsCurrentlyLoggingIn = true;

        var user = await _wordPressService.LoginAsync(Username, Password);
        if (user != null)
        {
            await _settingsService.SetAsync(Statics.UsernameSettingsKey, Username);
            await _settingsService.SetAsync(Statics.PasswordSettingsKey, Password);

            CurrentUser = user;
        }

        IsCurrentlyLoggingIn = false;
    }

    private void Logout()
    {
        _wordPressService.Logout();

        CurrentUser = null;
        Username = null;
        Password = null;

        _settingsService.Remove(Statics.UsernameSettingsKey);
        _settingsService.Remove(Statics.PasswordSettingsKey);
    }

    private bool CanLogin()
    {
        return !IsCurrentlyLoggingIn 
                && !string.IsNullOrEmpty(Username) 
                && !string.IsNullOrEmpty(Password);
    }
}

Nun müssen wir noch eine Option schaffen, dass der Nutzer auf die AccountPage navigieren kann. Den entsprechenden Command haben wir bereits angelegt und wir erweitern nun unsere PostOverviewPage und fügen ein ToolbarItem hinzu. Dieses nutzt den ShowAccountCommand, um dann entsprechend zur AccountPage zu navigieren. Hierzu verwenden wir ein Icon, welches wir dem Android- und auch dem iOS-Projekt hinzufügen. Die entsprechenden Icons könnt ihr dem Sourcecode am Ende des Beitrags entnehmen.

<ContentPage.ToolbarItems>
    <ToolbarItem Icon="account.png"
                 Command="{Binding ShowAccountCommand}" />
</ContentPage.ToolbarItems>

Nun öffnen wir noch die PostCommentPage und fügen noch ein Entry und einen Button dem Layout hinzu, so dass der Nutzer einen Kommentar verfassen und anschließend auch abschicken kann.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:XFWordPress.Controls"
             xmlns:markupextensions="clr-namespace:XFWordPress.MarkupExtensions"
             x:Class="XFWordPress.Views.PostCommentPage"
             Title="{markupextensions:Translate PostCommentPageTitle}"
             Padding="16">

    <ContentPage.Content>
        <Grid RowDefinitions="*,Auto">
            <CollectionView ItemsSource="{Binding Comments}"
                            Grid.Row="0">
                <CollectionView.ItemsLayout>
                    <GridItemsLayout Orientation="Vertical"
                                     VerticalItemSpacing="12" />
                </CollectionView.ItemsLayout>
                <CollectionView.EmptyView>
                    <Label Text="{markupextensions:Translate 
                        PostCommentPageNoCommentsLabel}" />
                </CollectionView.EmptyView>
                <CollectionView.ItemTemplate>
                    <DataTemplate>
                        <controls:CommentControl 
                            Comment="{Binding Content.Rendered}"
                            Author="{Binding AuthorName}" />
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>

            <Grid ColumnDefinitions="*,Auto"
                  Grid.Row="1">
                <Entry Placeholder="{markupextensions:Translate 
                    PostCommentPageCommentLabelPlaceholder}"
                       Text="{Binding CommentText}"
                       Grid.Column="0" />
                <Button Text="{markupextensions:Translate 
                    PostCommentPageCommentButton}"
                        Command="{Binding PostCommentAsyncCommand}"
                        VerticalOptions="End"
                        Grid.Column="1" />
            </Grid>
        </Grid>
    </ContentPage.Content>
</ContentPage>

Damit haben wir alle Puzzle-Teile für diesen Beitrag zusammen. Wie ihr bereits von den vorherigen Beiträgen kennt, habe ich nicht jeden einzelnen Schritt aufgeführt, aber ihr findet weiter unten den vollständigen Code zum Ausprobieren.

Hier könnt ihr das fertige Ergebnis aus diesem Beitrag sehen.

Der folgende Download-Button beinhaltet die aktuelle Solution.

Damit sind wir erst einmal am Ende der Serie angekommen und wir haben in wenigen Schritten eine App geschrieben, welche es ermöglicht Daten von einer WordPress-Webseite abzurufen und entsprechend darzustellen. Natürlich gebe es noch Optimierungspotenzial, wie zum Beispiel eine Offline-Fähigkeit oder das Tracken von Nutzer-Interaktionen. Daher kann ich mir vorstellen, dass es zu einem späteren Zeitpunkt mit dieser Serie weitergeht.

Zugriff auf localhost vom Android Emulator Einen Tweet aus C# veröffentlichen Eigene Schriftarten in Xamarin.Forms