Source code of the mobile apps for Power Planner... they're open source!
App store links |
---|
Windows store |
Google Play store |
Apple App store |
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...
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.
Detailed step-by-step instructions are available here. The instructions below are cliff notes meant for devs experienced with Windows and Xamarin development.
git submodule update --init --recursive
.\ApplySecrets.ps1
secrets.json
file (ignored from git), and generates the corresponding secret files needed to compile the appsecrets.json
file with the secrets and re-run .\ApplySecrets.ps1
PowerPlannerApps.sln
solution and you should be able to build the projects! See below for how to build each project.PowerPlannerUWP (Universal Windows)
, and ensure the build config is set to Debug
and architecture is one of x86
, x64
, or ARM
(Windows Phone only)PowerPlannerDroid
, and ensure the build config is set to Debug
and architecture is Any CPU
Prereqs...
maui
workloadInstructions...
cd
to the PowerPlanneriOS
directory.dotnet run
dotnet run /p:_DeviceName=:v2:udid=78E660EB-A32F-4683-95CC-CA663D7D64D0
using the identifier you copied.All three apps use a common shared data layer - PowerPlannerAppDataLibrary
. The data layer handles...
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) },
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.
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/SwitchRepeats"
android:text="{RepeatingEntry_CheckBoxRepeats.Content}"
android:textSize="16sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
local:Binding="{Source=Repeats, Target=Checked, Mode=TwoWay}; {Source=IsRepeatsVisible, Target=Visibility, Converter=BoolToVisibilityConverter}"/>
Converters are auto-discovered using reflection and their class name.
There are some implicit converters which live in BindingApplicator.SetTargetProperty.
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));
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...
PowerPlannerAppDataLibrary/Strings/Resources.resx
PowerPlannerAppDataLibrary
projectPowerPlannerAppDataLibrary/MultilingualResources
files have been updated... but they don't have translations yet.xlf
files and select Multilingual App Toolkit -> Generate machine translations. This will only generate translations for new strings..xlf
file (the diff view in VS works well), find the newly added strings, review them, and set their <target state="final">
.PowerPlannerAppDataLibrary
once again, and notice that the PowerPlannerAppDataLibrary/Strings/Resources.*.resx
files have been updatedTo access a localized string programmatically...
Title = PowerPlannerResources.GetString("ViewGradePage.Title");
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"/>
The resources can use .
to set properties, like the .Label
causes the label property to be localized with the value in the resources.
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.
new PortableMessageDialog("my content", "my title").Show(); // or ShowAsync() if you want to wait for it to be closed
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 { }