Skip to content

Antyos/powerplannerapps

 
 

Repository files navigation

Power Planner apps

Source code of the mobile apps for Power Planner... they're open source!

App store links
Windows store
Google Play store
Apple App store

Overview

Power Planner is a cross-platform academic app for students, complete with online sync, homework, schedules, grade calculation, and more.

The apps in this repro include the following platforms...

  • UWP (C#)
  • Android (C# Xamarin native)
  • iOS (C# Xamarin native)

The apps all share a common C# data library, which does all of the syncing, storage, and other model/view model logic.

Each platform-specific app simply needs to build views on top of the shared view model.

Prerequisites

  • Visual Studio 2019 with Xamarin and UWP SDKs
  • If building iOS version, Mac needed to build/run the iOS version

Getting started

Detailed step-by-step instructions are available here. The instructions below are cliff notes meant for devs experienced with Windows and Xamarin development.

  1. Be sure to clone submodules too. If you didn't, git submodule update --init --recursive
  2. For the first time after cloning, generate the secrets
    1. In the top-level directory, open PowerShell and run .\ApplySecrets.ps1
      1. This will generate a blank secrets.json file (ignored from git), and generates the corresponding secret files needed to compile the app
      2. If you have actual secrets to use, update the secrets.json file with the secrets and re-run .\ApplySecrets.ps1
      3. Note that the app still should compile and run without actual secrets, but things like accessing the server won't work (offline accounts should work though).
  3. Open the PowerPlannerApps.sln solution and you should be able to build the projects! See below for how to build each project.

Building UWP

  1. Set the start up project to PowerPlannerUWP (Universal Windows), and ensure the build config is set to Debug and architecture is one of x86, x64, or ARM (Windows Phone only)
  2. Click Local Machine to deploy!

Building Android

  1. Set the start up project to PowerPlannerDroid, and ensure the build config is set to Debug (Droid) (important that it must be the (Droid) config) and architecture is Any CPU
  2. Click Local Machine to deploy!

Building iOS

  1. Set the start up project to PowerPlanneriOS, and ensure the build config is set to Debug and architecture is Any CPU
  2. Click Local Machine to deploy!

Architecture

Data layer

All three apps use a common shared data layer - PowerPlannerAppDataLibrary. The data layer handles...

  • Connecting to the server
  • Syncing between local client and server
  • Storing all local content and accounts

View model layer

All three apps also use a common view model layer, contained in PowerPlannerAppDataLibrary. The view model is a virtual representation of the pages that should be shown to the user. It has concepts like popups, navigation, etc.

The view model layer is written using the custom BareMvvm.Core project.

public class WelcomeViewModel : BaseViewModel
{
    public WelcomeViewModel(BaseViewModel parent) : base(parent) { }

    public void Login()
    {
        ShowPopup(new LoginViewModel(this));
    }

    public void CreateAccount()
    {
        ShowPopup(new CreateAccountViewModel(this));
    }

Then there are platform-specific presenter libraries, contained in InterfacesDroid, InterfacesiOS, and InterfacesUWP. The presenter library binds to the view model and accordingly shows views as they're created, hides views as they're removed, etc.

        private void UpdateContent()
        {
            KeyboardHelper.HideKeyboard(this);

            // Remove previous content
            base.RemoveAllViews();

            if (ViewModel?.Content != null)
            {
                // Create and set new content
                var view = ViewModelToViewConverter.Convert(this, ViewModel.Content);
                base.AddView(view);
            }

Views are registered to ViewModels so that the presenter knows which view to create corresponding to the current view model. They are registered in...

  • PowerPlannerUWP/App.xaml.cs
  • PowerPlannerAndroid/App/NativeApplication.cs
  • PowerPlanneriOS/AppDelegate.cs
return new Dictionary<Type, Type>()
{
    { typeof(WelcomeViewModel), typeof(WelcomeView) },
    { typeof(LoginViewModel), typeof(LoginView) },
    { typeof(MainScreenViewModel), typeof(MainScreenView) },

Helpful binding logic

Views drastically depend on being able to bind to view model properties, so that the view magically updates when a property changes.

Anything that needs bindable properties should extend from BindableBase. Each property should then have a private and public property, as seen below, and when setting the private field, be sure to use the SetProperty method (provided by BindableBase), and reference the name of the property that changed (by using nameof to ensure that if you rename the property, refactoring will update everywhere).

public class Grade : BindableBase
{
    private double _gradeReceived;
    public double GradeReceived
    {
        get => _gradeReceived;
        set => SetProperty(ref _gradeReceived, value, nameof(GradeReceived));
    }
}

Sometimes there are computed properties that are dependent on other properties. There's another helper method in BindableBase for that... CachedComputation. Use it as follows...

public class Grade : BindableBase
{
    private double _gradeReceived;
    public double GradeReceived
    {
        get => _gradeReceived;
        set => SetProperty(ref _gradeReceived, value, nameof(GradeReceived));
    }
    
    // ...
    
    public double Percentage => CachedComputation(delegate
    {
        return GradeReceived / GradeTotal;
    }, new string[] { nameof(GradeReceived), nameof(GradeTotal) });
}

Notice that you have to explicitly reference (via nameof) the dependent properties you want to listen to. When one of those properties changes, the computation will run again, and if the result changes, it will trigger a property change event for that property.

iOS-specific binding examples

Within a ViewController, to add a generic binding that can perform any action, do the following... But note there's specific bindings for common tasks like binding text that you should use instead.

BindingHost.SetBinding(nameof(ViewModel.IsSyncing), delegate
{
    if (ViewModel.IsSyncing)
    {
        // Do whatever you want here
    }
    else
    {
        // Do whatever you want here
    }
});

To bind text...

BindingHost.SetLabelTextBinding(labelErrorDescription, nameof(ViewModel.Error));

To bind text boxes... (It's two-way binding by default)

BindingHost.SetTextFieldTextBinding(myTextField, nameof(ViewModel.Name));

You'll notice there's also lots of other binding options for binding visibility, color, etc.

To bind visibility where you need the item to collapse, you can either put the item in a BareUIVisibilityContainer and set the Child to your content and then set the visibilty binding on the visibility container (iOS doesn't have the concept of visibility on elements themselves, that's why we have to add it in a container)...

var pickerCustomTimeContainer = new BareUIVisibilityContainer()
{
    Child = stackViewPickerCustomTime // Your content that you want visible/collapsed
};
BindingHost.SetVisibilityBinding(pickerCustomTimeContainer, nameof(ViewModel.IsStartTimePickerVisible));

Alternatively, if you're adding content into a StackView, you can use a simpler method for toggling visibility... Use the AddUnderVisibility extension on the StackView to add the item instead of using AddArrangedSubview. This will automatically use the BareUIVisibilityContainer under the scenes.

var progressBar = new UIProgressView(UIProgressViewStyle.Default)
{
    TranslatesAutoresizingMaskIntoConstraints = false
};
viewCenterContainer.AddUnderVisiblity(progressBar, BindingHost, nameof(ViewModel.IsSyncing));

If you just need the item to be hidden but not actually collapsed, you can use SetVisibilityBinding directly on the view rather than wrapping it in a BareUiVisibilityContainer.

BindingHost.SetVisibilityBinding(buttonSettings, nameof(ViewModel.IsSyncing));

Localization

The Android and UWP apps are currently fully localized. Localized strings are found in PowerPlannerAppDataLibrary/Strings. iOS has not been updated to take advantage of the localized strings (text is hardcoded right now).

The multilingual app toolkit by Microsoft is used to help auto-generate translations. The process for adding a new string is as follows...

  1. Add the new English string in PowerPlannerAppDataLibrary/Strings/Resources.resx
  2. Build the PowerPlannerAppDataLibrary project
  3. Notice that the PowerPlannerAppDataLibrary/MultilingualResources files have been updated... but they don't have translations yet
  4. If you're using the multilingual app toolkit, right click on one of those multilingual .xlf files and select Multilingual App Toolkit -> Generate machine translations. This will only generate translations for new strings.
  5. Now, open the .xlf file (the diff view in VS works well), find the newly added strings, review them, and set their <target state="final">.
  6. Build PowerPlannerAppDataLibrary once again, and notice that the PowerPlannerAppDataLibrary/Strings/Resources.*.resx files have been updated

To access a localized string programmatically...

Title = PowerPlannerResources.GetString("ViewGradePage.Title");

UWP-specific localization considerations

UWP supports localization within the XAML markup, using x:Uid. For example, the Label property of the following control is localized...

<AppBarButton
    x:Uid="AppBarButtonSave"
    x:Name="ButtonSave"
    Icon="Save"
    Label="Save"
    Click="ButtonSave_Click"/>

image

The resources can use . to set properties, like the .Label causes the label property to be localized with the value in the resources.

Android-specific localization considerations

In Android, you can also localize directly in the XML layout views. But this uses custom syntax part of a custom Android layout binding language.

<TextView
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="{Settings_GradeOptions_GpaType_StandardExplanation.Text}"
  android:textSize="12sp"
  android:textColor="#000000"
  android:layout_marginTop="4dp"/>

Simply place the resource string's id within {}. I can't remember whether localization is supported on any text property, or only specific ones like TextView.text... it might be supported on any.

Helpful code snippets

Showing a dialog cross-platform

new PortableMessageDialog("my content", "my title").Show(); // or ShowAsync() if you want to wait for it to be closed

A/B Testing

There's already code to help with performing A/B tests.

To add a new test, in PowerPlannerAppDataLibrary\Helpers\AbTestHelper.cs, add a new test to the Tests class. You can set the boolean to true or false, which is only used in debug mode, so that you can test both scenarios.

public static class Tests
{
    public static TestItem NewTimePicker { get; set; } = new TestItem(nameof(NewTimePicker), true); // The boolean at the end is only used in debug mode, so that you can enable or disable the test. In release mode, it'll be randomly enabled/disabled.
}

To change your code programmatically based on the test value...

if (AbTestHelper.Tests.NewTimePicker)
{
    // Perform code when enabled
}
else
{
    // Perform the old code
}

If you have UI you need to swap out, specify the name of the test and provide the enabled and disabled content...

<controls:AbTestControl TestName="NewTimePicker">
    <controls:AbTestControl.EnabledContent>
        <controls:TimePickerControl
            x:Uid="EditingClassScheduleItemView_TimePickerStart"
            Margin="6"
            HorizontalAlignment="Stretch"
            controls:TimePickerControl.Header="From"
            controls:TimePickerControl.IsEndTime="False"/>
    </controls:AbTestControl.EnabledContent>
    <controls:AbTestControl.DisabledContent>
        <TimePicker
            x:Uid="EditingClassScheduleItemView_TimePickerStart"
            Header="From"
            HorizontalAlignment="Stretch"
            Time="{Binding StartTime, Mode=TwoWay}"/>
    </controls:AbTestControl.DisabledContent>
</controls:AbTestControl>

And finally, to log metrics of the test, do something like...

try
{
    TelemetryExtension.Current?.TrackEvent("NewTimePicker_TestResult", new Dictionary<string, string>()
    {
        { "Duration", ((int)Math.Ceiling(duration.TotalSeconds)).ToString() },
        { "IsEnabled", AbTestHelper.Tests.NewTimePicker.Value.ToString() }
    });
}
catch { }

About

The mobile apps for Power Planner

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • C# 100.0%