diff --git a/FlowerApp/App.xaml b/FlowerApp/App.xaml
index 9959494..715938f 100644
--- a/FlowerApp/App.xaml
+++ b/FlowerApp/App.xaml
@@ -9,6 +9,8 @@
+
+
diff --git a/FlowerApp/AppShell.xaml b/FlowerApp/AppShell.xaml
index 7e5f03c..9f45c91 100644
--- a/FlowerApp/AppShell.xaml
+++ b/FlowerApp/AppShell.xaml
@@ -9,7 +9,7 @@
+ Route="Garden" Icon="flower_tulip.png">
(BindableProperty property)
{
@@ -17,7 +24,7 @@ public class AppContentPage : ContentPage, ILoggerContent
bool hasLoading = true;
ContentView? loading;
-#if __IOS__
+#if IOS
private async Task DoLoading(bool flag)
#else
private Task DoLoading(bool flag)
@@ -38,7 +45,7 @@ public class AppContentPage : ContentPage, ILoggerContent
{
if (flag)
{
-#if __IOS__
+#if IOS
loading.IsVisible = true;
await loading.FadeTo(1, easing: Easing.CubicOut);
#else
@@ -48,7 +55,7 @@ public class AppContentPage : ContentPage, ILoggerContent
}
else
{
-#if __IOS__
+#if IOS
await loading.FadeTo(0, easing: Easing.CubicIn);
loading.IsVisible = false;
#else
@@ -57,7 +64,7 @@ public class AppContentPage : ContentPage, ILoggerContent
#endif
}
}
-#if __ANDROID__
+#if ANDROID
return Task.CompletedTask;
#endif
}
@@ -79,4 +86,141 @@ public class AppContentPage : ContentPage, ILoggerContent
});
return source.Task;
}
+
+ async Task GetLastLocationAsyncInternal()
+ {
+ try
+ {
+ var location = await Geolocation.Default.GetLastKnownLocationAsync();
+ return location;
+ }
+ catch (FeatureNotSupportedException fnsEx)
+ {
+ this.LogError(fnsEx, $"Not supported on device, {fnsEx.Message}.");
+ }
+ catch (FeatureNotEnabledException fneEx)
+ {
+ this.LogError(fneEx, $"Not enabled on device, {fneEx.Message}.");
+ }
+ catch (PermissionException)
+ {
+ this.LogWarning($"User denied.");
+ }
+ catch (Exception ex)
+ {
+ this.LogError(ex, $"Error occurs while getting cached location, {ex.Message}");
+ }
+ return null;
+ }
+
+ protected Task GetLastLocationAsync()
+ {
+ if (MainThread.IsMainThread)
+ {
+ return GetLastLocationAsyncInternal();
+ }
+ var source = new TaskCompletionSource();
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ var location = await GetLastLocationAsyncInternal();
+ source.TrySetResult(location);
+ });
+ return source.Task;
+ }
+
+ TaskCompletionSource? locationTaskSource;
+ CancellationTokenSource? locationCancellationTokenSource;
+
+ async Task GetCurrentLocationAsyncInternal()
+ {
+ if (locationTaskSource == null)
+ {
+ locationTaskSource = new TaskCompletionSource();
+
+ try
+ {
+ var request = new GeolocationRequest(GeolocationAccuracy.Best, TimeSpan.FromSeconds(10));
+#if IOS
+ request.RequestFullAccuracy = true;
+#endif
+
+ locationCancellationTokenSource = new CancellationTokenSource();
+
+ var location = await Geolocation.Default.GetLocationAsync(request, locationCancellationTokenSource.Token);
+ locationTaskSource.SetResult(location);
+ }
+ catch (Exception ex)
+ {
+ this.LogError(ex, $"Error occurs while getting current location, {ex.Message}");
+ }
+ }
+
+ return await locationTaskSource.Task;
+ }
+
+ protected Task GetCurrentLocationAsync()
+ {
+ if (MainThread.IsMainThread)
+ {
+ return GetCurrentLocationAsyncInternal();
+ }
+ var source = new TaskCompletionSource();
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ var location = await GetCurrentLocationAsyncInternal();
+ source.TrySetResult(location);
+ });
+ return source.Task;
+ }
+
+ protected void CancelRequestLocation()
+ {
+ if (locationCancellationTokenSource?.IsCancellationRequested == false)
+ {
+ locationCancellationTokenSource.Cancel();
+ }
+ }
+
+ async Task TakePhotoInternal()
+ {
+ var status = await Permissions.CheckStatusAsync();
+
+ if (status == PermissionStatus.Denied)
+ {
+ await this.AlertError(L("needCameraPermission", "Flower Story needs access to the camera to take photos."));
+#if IOS
+ var settingsUrl = UIKit.UIApplication.OpenSettingsUrlString;
+ await Launcher.TryOpenAsync(settingsUrl);
+#endif
+ return null;
+ }
+
+ if (status != PermissionStatus.Granted)
+ {
+ status = await Permissions.RequestAsync();
+ }
+
+ if (status != PermissionStatus.Granted)
+ {
+ return null;
+ }
+
+ var file = await MediaPicker.Default.CapturePhotoAsync();
+ return file;
+ }
+
+ protected Task TakePhoto()
+ {
+ if (MainThread.IsMainThread)
+ {
+ return TakePhotoInternal();
+ }
+ var source = new TaskCompletionSource();
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ var file = await TakePhotoInternal();
+ source.TrySetResult(file);
+ });
+ return source.Task;
+ }
}
diff --git a/FlowerApp/Controls/AppConverters.cs b/FlowerApp/Controls/AppConverters.cs
index fd35382..33e5236 100644
--- a/FlowerApp/Controls/AppConverters.cs
+++ b/FlowerApp/Controls/AppConverters.cs
@@ -2,7 +2,7 @@
namespace Blahblah.FlowerApp;
-internal class VisibleIfNotNullConverter : IValueConverter
+class VisibleIfNotNullConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
@@ -18,3 +18,24 @@ internal class VisibleIfNotNullConverter : IValueConverter
throw new NotImplementedException();
}
}
+
+
+class DateTimeStringConverter : IValueConverter
+{
+ public string Format { get; init; } = "MM/dd HH:mm:ss";
+
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is long time)
+ {
+ var date = DateTimeOffset.FromUnixTimeMilliseconds(time);
+ return date.ToLocalTime().ToString(Format);
+ }
+ return value;
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
\ No newline at end of file
diff --git a/FlowerApp/Controls/FlowerClientItem.cs b/FlowerApp/Controls/FlowerClientItem.cs
index 10efb16..5c12f57 100644
--- a/FlowerApp/Controls/FlowerClientItem.cs
+++ b/FlowerApp/Controls/FlowerClientItem.cs
@@ -7,6 +7,7 @@ public class FlowerClientItem : BindableObject
{
static readonly BindableProperty NameProperty = CreateProperty(nameof(Name));
static readonly BindableProperty CategoryIdProperty = CreateProperty(nameof(CategoryId));
+ static readonly BindableProperty DaysProperty = CreateProperty(nameof(Days));
static readonly BindableProperty CoverProperty = CreateProperty(nameof(Cover));
static readonly BindableProperty BoundsProperty = CreateProperty(nameof(Bounds));
@@ -23,6 +24,11 @@ public class FlowerClientItem : BindableObject
get => (int)GetValue(CategoryIdProperty);
set => SetValue(CategoryIdProperty, value);
}
+ public string Days
+ {
+ get => (string)GetValue(DaysProperty);
+ set => SetValue(DaysProperty, value);
+ }
public ImageSource? Cover
{
get => (ImageSource?)GetValue(CoverProperty);
diff --git a/FlowerApp/Controls/ItemSelectorPage.cs b/FlowerApp/Controls/ItemSelectorPage.cs
new file mode 100644
index 0000000..cd86d1d
--- /dev/null
+++ b/FlowerApp/Controls/ItemSelectorPage.cs
@@ -0,0 +1,124 @@
+using static Blahblah.FlowerApp.Extensions;
+
+namespace Blahblah.FlowerApp.Controls;
+
+class ItemSelectorPage : ContentPage where T : IdTextItem
+{
+ public EventHandler? Selected;
+
+ public ItemSelectorPage(string title, T[] source, bool multiple = false, K[]? selected = null, string display = nameof(IdTextItem.Text), string? detail = null)
+ {
+ Title = title;
+
+ var itemsSource = source.Select(t => new SelectableItem
+ {
+ Item = t,
+ IsSelected = selected != null && selected.Contains(t.Id)
+ }).ToArray();
+
+ var list = new ListView
+ {
+ SelectionMode = ListViewSelectionMode.None,
+ ItemsSource = itemsSource,
+ ItemTemplate = new DataTemplate(() =>
+ {
+ var content = new Grid
+ {
+ Margin = new Thickness(12, 0),
+ ColumnSpacing = 12,
+ ColumnDefinitions =
+ {
+ new(30),
+ new(GridLength.Star),
+ new(GridLength.Auto)
+ },
+ Children =
+ {
+ new SecondaryLabel
+ {
+ VerticalOptions = LayoutOptions.Center,
+ HorizontalOptions = LayoutOptions.Center,
+ Text = Res.Check,
+ FontFamily = "FontAwesome"
+ }
+ .Binding(IsVisibleProperty, nameof(SelectableItem.IsSelected)),
+
+ new Label
+ {
+ VerticalOptions = LayoutOptions.Center
+ }
+ .Binding(Label.TextProperty, $"{nameof(SelectableItem.Item)}.{display}")
+ .GridColumn(1)
+ }
+ };
+ if (detail != null)
+ {
+ content.Children.Add(
+ new SecondaryLabel
+ {
+ VerticalOptions = LayoutOptions.Center
+ }
+ .Binding(Label.TextProperty, $"{nameof(SelectableItem.Item)}.{detail}")
+ .GridColumn(2));
+ }
+ return new ViewCell
+ {
+ View = content
+ };
+ })
+ };
+
+ list.ItemTapped += List_ItemTapped;
+
+ Content = list;
+ }
+
+ private async void List_ItemTapped(object? sender, ItemTappedEventArgs e)
+ {
+ if (e.Item is SelectableItem item)
+ {
+ Selected?.Invoke(this, item.Item);
+ await Navigation.PopAsync();
+ }
+ }
+}
+
+class SelectableItem : BindableObject
+{
+ public static BindableProperty IsSelectedProperty = CreateProperty>(nameof(IsSelected));
+ public static BindableProperty ItemProperty = CreateProperty>(nameof(Item));
+
+ public bool IsSelected
+ {
+ get => (bool)GetValue(IsSelectedProperty);
+ set => SetValue(IsSelectedProperty, value);
+ }
+ public T Item
+ {
+ get => (T)GetValue(ItemProperty);
+ set => SetValue(ItemProperty, value);
+ }
+}
+
+class IdTextItem : BindableObject
+{
+ public static BindableProperty IdProperty = CreateProperty>(nameof(Id));
+ public static BindableProperty TextProperty = CreateProperty>(nameof(Text));
+ public static BindableProperty DetailProperty = CreateProperty>(nameof(Detail));
+
+ public T Id
+ {
+ get => (T)GetValue(IdProperty);
+ set => SetValue(IdProperty, value);
+ }
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+ public string? Detail
+ {
+ get => (string?)GetValue(DetailProperty);
+ set => SetValue(DetailProperty, value);
+ }
+}
\ No newline at end of file
diff --git a/FlowerApp/Controls/OptionCell.cs b/FlowerApp/Controls/OptionCell.cs
new file mode 100644
index 0000000..26791f2
--- /dev/null
+++ b/FlowerApp/Controls/OptionCell.cs
@@ -0,0 +1,351 @@
+using System.ComponentModel;
+using static Blahblah.FlowerApp.Extensions;
+using static System.Runtime.InteropServices.JavaScript.JSType;
+
+namespace Blahblah.FlowerApp.Controls;
+
+public class TitleLabel : Label { }
+
+public class SecondaryLabel : Label { }
+
+public class IconLabel : Label { }
+
+public class OptionEntry : Entry { }
+
+public class OptionEditor : Editor { }
+
+public class OptionDatePicker : DatePicker { }
+
+public class OptionTimePicker : TimePicker { }
+
+public abstract class OptionCell : ViewCell
+{
+ public static readonly BindableProperty IconProperty = CreateProperty(nameof(Icon));
+ public static readonly BindableProperty TitleProperty = CreateProperty(nameof(Title));
+ public static readonly BindableProperty IsRequiredProperty = CreateProperty(nameof(IsRequired));
+
+ [TypeConverter(typeof(ImageSourceConverter))]
+ public ImageSource Icon
+ {
+ get => (ImageSource)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+ public bool IsRequired
+ {
+ get => (bool)GetValue(IsRequiredProperty);
+ set => SetValue(IsRequiredProperty, value);
+ }
+
+ protected abstract View Content { get; }
+
+ public OptionCell()
+ {
+ View = new Grid
+ {
+ BindingContext = this,
+ Padding = new Thickness(20, 0),
+ ColumnSpacing = 12,
+ ColumnDefinitions =
+ {
+ new(GridLength.Auto),
+ new(new GridLength(.35, GridUnitType.Star)),
+ new(new GridLength(.65, GridUnitType.Star))
+ },
+ RowDefinitions = { new(44) },
+ Children =
+ {
+ new Image
+ {
+ WidthRequest = 20,
+ HeightRequest = 20,
+ Aspect = Aspect.AspectFit,
+ VerticalOptions = LayoutOptions.Center
+ }
+ .Binding(VisualElement.IsVisibleProperty, nameof(Icon), converter: new VisibleIfNotNullConverter())
+ .Binding(Image.SourceProperty, nameof(Icon)),
+
+ new Grid
+ {
+ ColumnDefinitions =
+ {
+ new(GridLength.Auto),
+ new(GridLength.Star)
+ },
+ Children =
+ {
+ new TitleLabel
+ {
+ LineBreakMode = LineBreakMode.TailTruncation,
+ VerticalOptions = LayoutOptions.Center
+ }
+ .Binding(Label.TextProperty, nameof(Title)),
+
+ new SecondaryLabel
+ {
+ VerticalOptions = LayoutOptions.Center,
+ Text = "*"
+ }
+ .GridColumn(1)
+ .AppThemeBinding(Label.TextColorProperty, Res.Red100, Res.Red300)
+ .Binding(VisualElement.IsVisibleProperty, nameof(IsRequired)),
+ }
+ }
+ .GridColumn(1),
+
+ Content.GridColumn(2)
+ }
+ }
+ .AppThemeBinding(VisualElement.BackgroundColorProperty, Colors.White, Res.Gray900);
+ }
+}
+
+public abstract class OptionVerticalCell : OptionCell
+{
+ public OptionVerticalCell()
+ {
+ View = new Grid
+ {
+ BindingContext = this,
+ Padding = new Thickness(20, 0),
+ ColumnSpacing = 12,
+ ColumnDefinitions =
+ {
+ new(GridLength.Auto),
+ new(GridLength.Star)
+ },
+ RowDefinitions =
+ {
+ new(44),
+ new(GridLength.Star)
+ },
+ Children =
+ {
+ new Image
+ {
+ WidthRequest = 20,
+ HeightRequest = 20,
+ Aspect = Aspect.AspectFit,
+ VerticalOptions = LayoutOptions.Center
+ }
+ .Binding(VisualElement.IsVisibleProperty, nameof(Icon), converter: new VisibleIfNotNullConverter())
+ .Binding(Image.SourceProperty, nameof(Icon)),
+
+ new TitleLabel
+ {
+ LineBreakMode = LineBreakMode.TailTruncation,
+ VerticalOptions = LayoutOptions.Center
+ }
+ .GridColumn(1)
+ .Binding(Label.TextProperty, nameof(Title)),
+
+ Content.GridRow(1).GridColumn(1)
+ }
+ }
+ .AppThemeBinding(VisualElement.BackgroundColorProperty, Colors.White, Res.Gray900);
+ }
+}
+
+public class OptionTextCell : OptionCell
+{
+ public static readonly BindableProperty DetailProperty = CreateProperty(nameof(Detail));
+
+ public string Detail
+ {
+ get => (string)GetValue(DetailProperty);
+ set => SetValue(DetailProperty, value);
+ }
+
+ protected override View Content => new SecondaryLabel
+ {
+ HorizontalOptions = LayoutOptions.End,
+ VerticalOptions = LayoutOptions.Center
+ }
+ .Binding(Label.TextProperty, nameof(Detail));
+}
+
+public class OptionEntryCell : OptionCell
+{
+ public static readonly BindableProperty TextProperty = CreateProperty(nameof(Text), defaultBindingMode: BindingMode.TwoWay);
+ public static readonly BindableProperty KeyboardProperty = CreateProperty(nameof(Keyboard), defaultValue: Keyboard.Default);
+ public static readonly BindableProperty PlaceholderProperty = CreateProperty(nameof(Placeholder));
+
+ public event EventHandler? Unfocused;
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+ public Keyboard Keyboard
+ {
+ get => (Keyboard)GetValue(KeyboardProperty);
+ set => SetValue(KeyboardProperty, value);
+ }
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ protected override View Content
+ {
+ get
+ {
+ var entry = new OptionEntry()
+ .Binding(Entry.TextProperty, nameof(Text))
+ .Binding(InputView.KeyboardProperty, nameof(Keyboard))
+ .Binding(Entry.PlaceholderProperty, nameof(Placeholder));
+ entry.Unfocused += Entry_Unfocused;
+ return entry;
+ }
+ }
+
+ private void Entry_Unfocused(object? sender, FocusEventArgs e)
+ {
+ Unfocused?.Invoke(this, e);
+ }
+}
+
+public class OptionEditorCell : OptionVerticalCell
+{
+ public static readonly BindableProperty TextProperty = CreateProperty(nameof(Text), defaultBindingMode: BindingMode.TwoWay);
+ public static readonly BindableProperty KeyboardProperty = CreateProperty(nameof(Keyboard), defaultValue: Keyboard.Default);
+ public static readonly BindableProperty PlaceholderProperty = CreateProperty(nameof(Placeholder));
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+ public Keyboard Keyboard
+ {
+ get => (Keyboard)GetValue(KeyboardProperty);
+ set => SetValue(KeyboardProperty, value);
+ }
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ protected override View Content => new OptionEditor()
+ .Binding(Editor.TextProperty, nameof(Text))
+ .Binding(InputView.KeyboardProperty, nameof(Keyboard))
+ .Binding(Editor.PlaceholderProperty, nameof(Placeholder));
+}
+
+public class OptionSwitchCell : OptionCell
+{
+ public static readonly BindableProperty IsToggledProperty = CreateProperty(nameof(IsToggled), defaultBindingMode: BindingMode.TwoWay);
+
+ public bool IsToggled
+ {
+ get => (bool)GetValue(IsToggledProperty);
+ set => SetValue(IsToggledProperty, value);
+ }
+
+ protected override View Content => new Switch
+ {
+ HorizontalOptions = LayoutOptions.End,
+ VerticalOptions = LayoutOptions.Center
+ }
+ .Binding(Switch.IsToggledProperty, nameof(IsToggled));
+}
+
+public class OptionSelectCell : OptionTextCell
+{
+ public static readonly BindableProperty CommandProperty = CreateProperty(nameof(Command));
+ public static readonly BindableProperty CommandParameterProperty = CreateProperty