feature: add flower

This commit is contained in:
2023-08-02 23:45:04 +08:00
parent 155ca9ad9c
commit 31cfaee4f0
55 changed files with 2416 additions and 308 deletions

View File

@ -1,13 +1,20 @@
using Blahblah.FlowerApp.Data;
using Microsoft.Extensions.Logging;
using static Blahblah.FlowerApp.Extensions;
namespace Blahblah.FlowerApp;
public class AppContentPage : ContentPage, ILoggerContent
public abstract class AppContentPage : ContentPage, ILoggerContent
{
public ILogger Logger { get; init; } = null!;
public ILogger Logger { get; } = null!;
public FlowerDatabase Database { get; init; } = null!;
public FlowerDatabase Database { get; } = null!;
protected AppContentPage(FlowerDatabase database, ILogger logger)
{
Database = database;
Logger = logger;
}
protected T GetValue<T>(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<Location?> 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<Location?> GetLastLocationAsync()
{
if (MainThread.IsMainThread)
{
return GetLastLocationAsyncInternal();
}
var source = new TaskCompletionSource<Location?>();
MainThread.BeginInvokeOnMainThread(async () =>
{
var location = await GetLastLocationAsyncInternal();
source.TrySetResult(location);
});
return source.Task;
}
TaskCompletionSource<Location?>? locationTaskSource;
CancellationTokenSource? locationCancellationTokenSource;
async Task<Location?> GetCurrentLocationAsyncInternal()
{
if (locationTaskSource == null)
{
locationTaskSource = new TaskCompletionSource<Location?>();
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<Location?> GetCurrentLocationAsync()
{
if (MainThread.IsMainThread)
{
return GetCurrentLocationAsyncInternal();
}
var source = new TaskCompletionSource<Location?>();
MainThread.BeginInvokeOnMainThread(async () =>
{
var location = await GetCurrentLocationAsyncInternal();
source.TrySetResult(location);
});
return source.Task;
}
protected void CancelRequestLocation()
{
if (locationCancellationTokenSource?.IsCancellationRequested == false)
{
locationCancellationTokenSource.Cancel();
}
}
async Task<FileResult?> TakePhotoInternal()
{
var status = await Permissions.CheckStatusAsync<Permissions.Camera>();
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<Permissions.Camera>();
}
if (status != PermissionStatus.Granted)
{
return null;
}
var file = await MediaPicker.Default.CapturePhotoAsync();
return file;
}
protected Task<FileResult?> TakePhoto()
{
if (MainThread.IsMainThread)
{
return TakePhotoInternal();
}
var source = new TaskCompletionSource<FileResult?>();
MainThread.BeginInvokeOnMainThread(async () =>
{
var file = await TakePhotoInternal();
source.TrySetResult(file);
});
return source.Task;
}
}

View File

@ -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();
}
}

View File

@ -7,6 +7,7 @@ public class FlowerClientItem : BindableObject
{
static readonly BindableProperty NameProperty = CreateProperty<string, FlowerClientItem>(nameof(Name));
static readonly BindableProperty CategoryIdProperty = CreateProperty<int, FlowerClientItem>(nameof(CategoryId));
static readonly BindableProperty DaysProperty = CreateProperty<string, FlowerClientItem>(nameof(Days));
static readonly BindableProperty CoverProperty = CreateProperty<ImageSource?, FlowerClientItem>(nameof(Cover));
static readonly BindableProperty BoundsProperty = CreateProperty<Rect, FlowerClientItem>(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);

View File

@ -0,0 +1,124 @@
using static Blahblah.FlowerApp.Extensions;
namespace Blahblah.FlowerApp.Controls;
class ItemSelectorPage<K, T> : ContentPage where T : IdTextItem<K>
{
public EventHandler<T>? Selected;
public ItemSelectorPage(string title, T[] source, bool multiple = false, K[]? selected = null, string display = nameof(IdTextItem<K>.Text), string? detail = null)
{
Title = title;
var itemsSource = source.Select(t => new SelectableItem<T>
{
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<T>.IsSelected)),
new Label
{
VerticalOptions = LayoutOptions.Center
}
.Binding(Label.TextProperty, $"{nameof(SelectableItem<T>.Item)}.{display}")
.GridColumn(1)
}
};
if (detail != null)
{
content.Children.Add(
new SecondaryLabel
{
VerticalOptions = LayoutOptions.Center
}
.Binding(Label.TextProperty, $"{nameof(SelectableItem<T>.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<T> item)
{
Selected?.Invoke(this, item.Item);
await Navigation.PopAsync();
}
}
}
class SelectableItem<T> : BindableObject
{
public static BindableProperty IsSelectedProperty = CreateProperty<bool, SelectableItem<T>>(nameof(IsSelected));
public static BindableProperty ItemProperty = CreateProperty<T, SelectableItem<T>>(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<T> : BindableObject
{
public static BindableProperty IdProperty = CreateProperty<T, IdTextItem<T>>(nameof(Id));
public static BindableProperty TextProperty = CreateProperty<string, IdTextItem<T>>(nameof(Text));
public static BindableProperty DetailProperty = CreateProperty<string?, IdTextItem<T>>(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);
}
}

View File

@ -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<ImageSource, OptionCell>(nameof(Icon));
public static readonly BindableProperty TitleProperty = CreateProperty<string, OptionCell>(nameof(Title));
public static readonly BindableProperty IsRequiredProperty = CreateProperty<bool, OptionCell>(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<string, OptionTextCell>(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<string, OptionEntryCell>(nameof(Text), defaultBindingMode: BindingMode.TwoWay);
public static readonly BindableProperty KeyboardProperty = CreateProperty<Keyboard, OptionEntryCell>(nameof(Keyboard), defaultValue: Keyboard.Default);
public static readonly BindableProperty PlaceholderProperty = CreateProperty<string, OptionEntryCell>(nameof(Placeholder));
public event EventHandler<FocusEventArgs>? 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<string, OptionEditorCell>(nameof(Text), defaultBindingMode: BindingMode.TwoWay);
public static readonly BindableProperty KeyboardProperty = CreateProperty<Keyboard, OptionEditorCell>(nameof(Keyboard), defaultValue: Keyboard.Default);
public static readonly BindableProperty PlaceholderProperty = CreateProperty<string, OptionEditorCell>(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<bool, OptionSwitchCell>(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<Command, OptionSelectCell>(nameof(Command));
public static readonly BindableProperty CommandParameterProperty = CreateProperty<object, OptionSelectCell>(nameof(CommandParameter));
public Command Command
{
get => (Command)GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}
public object CommandParameter
{
get => GetValue(CommandParameterProperty);
set => SetValue(CommandParameterProperty, value);
}
public event EventHandler? DetailTapped;
protected override View Content
{
get
{
var tap = new TapGestureRecognizer();
tap.Tapped += OnTapped;
return new StackLayout
{
Orientation = StackOrientation.Horizontal,
HorizontalOptions = LayoutOptions.End,
Children =
{
new SecondaryLabel
{
VerticalOptions = LayoutOptions.Center,
}
.Binding(Label.TextProperty, nameof(Detail)),
new IconLabel
{
VerticalOptions = LayoutOptions.Center,
Margin = new Thickness(6, 0),
Text = Res.Right
}
},
GestureRecognizers = { tap }
};
}
}
private void OnTapped(object? sender, TappedEventArgs e)
{
DetailTapped?.Invoke(this, e);
}
}
public class OptionDateTimePickerCell : OptionCell
{
public static readonly BindableProperty DateProperty = CreateProperty<DateTime, OptionDateTimePickerCell>(nameof(Date), defaultBindingMode: BindingMode.TwoWay);
public static readonly BindableProperty TimeProperty = CreateProperty<TimeSpan, OptionDateTimePickerCell>(nameof(Time), defaultBindingMode: BindingMode.TwoWay);
public DateTime Date
{
get => (DateTime)GetValue(DateProperty);
set => SetValue(DateProperty, value);
}
public TimeSpan Time
{
get => (TimeSpan)GetValue(TimeProperty);
set => SetValue(TimeProperty, value);
}
protected override View Content => new Grid
{
ColumnDefinitions =
{
new(GridLength.Star),
new(GridLength.Auto),
new(GridLength.Auto)
},
ColumnSpacing = 6,
Children =
{
new OptionDatePicker()
.Binding(DatePicker.DateProperty, nameof(Date))
.GridColumn(1),
new OptionTimePicker()
.Binding(TimePicker.TimeProperty, nameof(Time))
.GridColumn(2)
}
};
}