using JiDangJia.Core;
using System.Globalization;
using System.Text.Json;
namespace JiDangJia.Views;
public enum FieldKind { Text,Number,Choice,SearchChoice,Date,Notes,Photos,Toggle }
public record Field(string Key,string Label,FieldKind Kind=FieldKind.Text,bool Required=false,Dictionary<string,string>? Choices=null,string? Hint=null);
public sealed class FieldSet
{
public VerticalStackLayout View {get;}=new() {Spacing=16};
private readonly Dictionary<string,Func<bool,object?>> getters=new();
private readonly Dictionary<string,Label> labels=new();
private readonly Dictionary<string,View> groups=new();
public event Action? Changed;
public Dictionary<string,VisualElement> Inputs {get;}=new();
public Dictionary<string,PhotoField> Photos {get;}=new();
private readonly DbRow initial;
public FieldSet(DbRow initial,IEnumerable<Field> fields)
{
this.initial=initial;
foreach(var f in fields)Add(f);
}
private void Add(Field f)
{
View input;var label=Ui.Text(f.Label+(f.Required?" *":""),12,Ui.Muted);labels[f.Key]=label;var group=Ui.Stack(label);group.Spacing=6;groups[f.Key]=group;
switch(f.Kind)
{
case FieldKind.Number:
case FieldKind.Text:
var e=new Entry { Text=initial.Text(f.Key),Placeholder=f.Hint??"请输入"+f.Label,Keyboard=f.Kind==FieldKind.Number?Keyboard.Numeric:f.Key.Contains("phone",StringComparison.OrdinalIgnoreCase)?Keyboard.Telephone:Keyboard.Default,MaxLength=f.Kind==FieldKind.Number?14:200,HeightRequest=45 };
e.TextChanged+=(_,_)=>Changed?.Invoke();
if(f.Kind==FieldKind.Number&&(!f.Required||f.Key.Contains("Rate",StringComparison.OrdinalIgnoreCase)))
e.Unfocused+=(_,_)=>{if(string.IsNullOrWhiteSpace(e.Text))e.Text="0";};
input=e;getters[f.Key]=validate=> {if(f.Kind!=FieldKind.Number)return e.Text?.Trim()??"";if(string.IsNullOrWhiteSpace(e.Text)&&!f.Required)return null;if(!decimal.TryParse(e.Text,NumberStyles.Number,CultureInfo.InvariantCulture,out var n)){if(validate)throw new ArgumentException(f.Label+"请填写数字");return e.Text;}return n;};break;
case FieldKind.Date:
var d=new DatePicker {Date=initial.Text(f.Key).Length>0?initial.Date(f.Key):null,Format="yyyy-MM-dd",HeightRequest=45};input=d;getters[f.Key]=_=>d.Date.HasValue?Display.Day(d.Date.Value):null;break;
case FieldKind.Choice:
var choices=f.Choices??new();var keys=choices.Keys.ToArray();var picker=new Picker {Title="请选择"+f.Label,ItemsSource=choices.Values.ToList(),HeightRequest=45};
picker.SelectedIndex=Array.IndexOf(keys,initial.Text(f.Key));picker.SelectedIndexChanged+=(_,_)=>Changed?.Invoke();input=picker;getters[f.Key]=_=>picker.SelectedIndex>=0?choices.Keys.ElementAtOrDefault(picker.SelectedIndex)??"":"";break;
case FieldKind.SearchChoice:
var select=new SearchChoiceInput(f.Label,f.Choices??new(),initial.Text(f.Key));select.Changed+=()=>Changed?.Invoke();input=select;getters[f.Key]=_=>select.Value;break;
case FieldKind.Toggle:
var toggle=new Switch {IsToggled=initial.Int(f.Key)==1,OnColor=Ui.Bronze,HorizontalOptions=LayoutOptions.Start};input=toggle;getters[f.Key]=_=>toggle.IsToggled?1:0;break;
case FieldKind.Notes:
var editor=new Editor {Text=initial.Text(f.Key),Placeholder=f.Hint??"填写"+f.Label,AutoSize=EditorAutoSizeOption.TextChanges,MinimumHeightRequest=80,MaxLength=10000};input=editor;getters[f.Key]=_=>editor.Text?.Trim()??"";break;
case FieldKind.Photos:
var photos=new PhotoField(initial.Text(f.Key),f.Key=="adPhotos"?1:20,f.Label);Photos[f.Key]=photos;input=photos.View;getters[f.Key]=_=>photos.Value;break;
default:throw new ArgumentOutOfRangeException();
}
Inputs[f.Key]=input;
group.Add(f.Kind is FieldKind.Toggle or FieldKind.Photos or FieldKind.SearchChoice?input:Ui.Card(input,12,new Thickness(12,0),Ui.Paper));
View.Add(group);
if(f.Required) {var getter=getters[f.Key];getters[f.Key]=validate=> {var value=getter(validate);if(validate && (value is null || string.IsNullOrWhiteSpace(Convert.ToString(value,CultureInfo.InvariantCulture))))throw new ArgumentException("请填写"+f.Label);return value;};}
}
public DbRow Read(bool validate=true) {var row=new DbRow(initial);foreach(var p in getters){if(groups.TryGetValue(p.Key,out var group)&&!group.IsVisible)row.Remove(p.Key);else row[p.Key]=p.Value(validate);}return row;}
public void Computed(string key,Func<object?> getter)=>getters[key]=_=>getter();
public void SetLabel(string key,string text) {if(labels.TryGetValue(key,out var label))label.Text=text;}
public void SetVisible(string key,bool visible) {if(groups.TryGetValue(key,out var group))group.IsVisible=visible;}
}
public static class MediaFiles
{
public static ImageSource Source(string value)
{
if(value.StartsWith("data:image/",StringComparison.OrdinalIgnoreCase)) {var bytes=Convert.FromBase64String(value[(value.IndexOf(',')+1)..]);return ImageSource.FromStream(()=>new MemoryStream(bytes));}
return ImageSource.FromFile(value);
}
public static List<string> Parse(string value)
{
if(string.IsNullOrWhiteSpace(value))return [];
try {return JsonSerializer.Deserialize<List<string>>(value)??[];}catch(JsonException){return [value];}
}
public static async Task<string?> Pick(bool camera)
{
FileResult? result;var account=Services.AuthClient.Current.AccountId;
using(new Services.ExternalPhotoScope())
{
#if ANDROID
if(camera&&await Permissions.RequestAsync<Permissions.Camera>()!=PermissionStatus.Granted)throw new InvalidOperationException("请允许使用相机后重试");
result=await MediaPhotoActivity.Pick(camera);
#else
result=camera?await MediaPicker.Default.CapturePhotoAsync():await FilePicker.Default.PickAsync(new PickOptions {PickerTitle="选择照片",FileTypes=FilePickerFileType.Images});
#endif
}
if(result is null)return null;
try{return account==Services.AuthClient.Current.AccountId?await ReadPhoto(result,false):null;}
finally
{
#if ANDROID
File.Delete(result.FullPath);
#endif
}
}
public static async Task<string> ReadPhoto(FileResult result,bool camera)
{
byte[] bytes;
try{await using var stream=await result.OpenReadAsync();using var buffer=new MemoryStream();await stream.CopyToAsync(buffer);bytes=buffer.ToArray();}
finally
{
// CapturePhoto creates an app cache copy. Keep external originals selected by the user.
if(camera&&!string.IsNullOrWhiteSpace(result.FullPath)&&Path.IsPathRooted(result.FullPath))
{
var cache=Path.GetFullPath(FileSystem.CacheDirectory)+Path.DirectorySeparatorChar;var captured=Path.GetFullPath(result.FullPath);
if(captured.StartsWith(cache,StringComparison.Ordinal))File.Delete(captured);
}
}
#if ANDROID
using var bounds=new Android.Graphics.BitmapFactory.Options {InJustDecodeBounds=true};
Android.Graphics.BitmapFactory.DecodeByteArray(bytes,0,bytes.Length,bounds);
if(bounds.OutWidth<1||bounds.OutHeight<1||(long)bounds.OutWidth*bounds.OutHeight>100_000_000)throw new ArgumentException("照片尺寸无效或过大");
int sample=1;while(Math.Max(bounds.OutWidth,bounds.OutHeight)/sample>1600)sample*=2;
using var opts=new Android.Graphics.BitmapFactory.Options {InSampleSize=sample};
using var bitmap=Android.Graphics.BitmapFactory.DecodeByteArray(bytes,0,bytes.Length,opts)??throw new InvalidOperationException("无法读取图片");
// Correct EXIF orientation before compression discards it, so the source watermark stays at the visible top right.
int orientation=1;try{using var metadata=new MemoryStream(bytes);using var exif=new AndroidX.ExifInterface.Media.ExifInterface(metadata);orientation=exif.GetAttributeInt(AndroidX.ExifInterface.Media.ExifInterface.TagOrientation,1);}catch(Java.IO.IOException){}
using var jpeg=new MemoryStream();
if(orientation is >=2 and <=8)
{
using var matrix=new Android.Graphics.Matrix();matrix.SetValues(orientation switch
{
2=>[-1,0,0,0,1,0,0,0,1],3=>[-1,0,0,0,-1,0,0,0,1],4=>[1,0,0,0,-1,0,0,0,1],
5=>[0,1,0,1,0,0,0,0,1],6=>[0,-1,0,1,0,0,0,0,1],7=>[0,-1,0,-1,0,0,0,0,1],_=>[0,1,0,-1,0,0,0,0,1]
});
using var upright=Android.Graphics.Bitmap.CreateBitmap(bitmap,0,0,bitmap.Width,bitmap.Height,matrix,true)!;upright.Compress(Android.Graphics.Bitmap.CompressFormat.Jpeg!,82,jpeg);
}
else bitmap.Compress(Android.Graphics.Bitmap.CompressFormat.Jpeg!,82,jpeg);bytes=jpeg.ToArray();
#endif
return "data:image/jpeg;base64,"+Convert.ToBase64String(bytes);
}
}
public sealed class PhotoField
{
private readonly List<string> images;
private readonly HorizontalStackLayout strip=new() {Spacing=8};
private readonly int limit;
private readonly string label;
public ScrollView View {get;}
public string Value=>JsonSerializer.Serialize(images);
public PhotoField(string value,int limit=20,string label="照片") {this.limit=limit;this.label=label;images=MediaFiles.Parse(value);View=new ScrollView {Content=strip,Orientation=ScrollOrientation.Horizontal,HorizontalScrollBarVisibility=ScrollBarVisibility.Never};Render();}
private void Render()
{
strip.Clear();
foreach(var p in images.ToArray())
{
var img=new Image {Source=MediaFiles.Source(p),Aspect=Aspect.AspectFill,WidthRequest=57,HeightRequest=65};
strip.Add(Ui.Tap(Ui.Card(img,9,new Thickness(0)),async()=> {var page=Application.Current!.Windows[0].Page!;if(await page.DisplayAlertAsync("移除照片","从当前记录移除此照片?","移除","保留")){images.Remove(p);Render();}}));
}
var add=Ui.Card(Ui.Icon("plus",Ui.Bronze,23),10,new Thickness(17),Ui.Paper);
strip.Add(Ui.Tap(add,async()=> {if(images.Count>=limit)throw new ArgumentException("此处最多添加 "+limit+" 张照片,请先移除已有照片");
#if !COMMUNITY_APP
var form=(Application.Current!.Windows[0].Page as NavigationPage)?.CurrentPage as FormPage;form?.PreserveDraft();
#endif
var action=await Application.Current!.Windows[0].Page!.DisplayActionSheetAsync("添加照片","取消",null,"拍照","相册");if(action is not ("拍照" or "相册"))return;
#if COMMUNITY_APP
var p=await MediaFiles.Pick(action=="拍照");
#else
var draft=action=="拍照"?form?.CameraDraft?.Invoke(this):null;var p=draft is null?await MediaFiles.Pick(action=="拍照"):await Services.OrderCameraRecovery.Capture(draft);
#endif
if(p is not null) {images.Add(p);Render();
#if !COMMUNITY_APP
form?.PreserveDraft();
#endif
}},"添加"+label));
}
}
using JiDangJia.Core;
using System.Globalization;
using System.Text.Json;
namespace JiDangJia.Views;
public enum FieldKind { Text,Number,Choice,SearchChoice,Date,Notes,Photos,Toggle }
public record Field(string Key,string Label,FieldKind Kind=FieldKind.Text,bool Required=false,Dictionary<string,string>? Choices=null,string? Hint=null);
public sealed class FieldSet
{
public VerticalStackLayout View {get;}=new() {Spacing=16};
private readonly Dictionary<string,Func<bool,object?>> getters=new();
private readonly Dictionary<string,Label> labels=new();
private readonly Dictionary<string,View> groups=new();
public event Action? Changed;
public Dictionary<string,VisualElement> Inputs {get;}=new();
public Dictionary<string,PhotoField> Photos {get;}=new();
private readonly DbRow initial;
public FieldSet(DbRow initial,IEnumerable<Field> fields)
{
this.initial=initial;
foreach(var f in fields)Add(f);
}
private void Add(Field f)
{
View input;var label=Ui.Text(f.Label+(f.Required?" *":""),12,Ui.Muted);labels[f.Key]=label;var group=Ui.Stack(label);group.Spacing=6;groups[f.Key]=group;
switch(f.Kind)
{
case FieldKind.Number:
case FieldKind.Text:
var e=new Entry { Text=initial.Text(f.Key),Placeholder=f.Hint??"请输入"+f.Label,Keyboard=f.Kind==FieldKind.Number?Keyboard.Numeric:f.Key.Contains("phone",StringComparison.OrdinalIgnoreCase)?Keyboard.Telephone:Keyboard.Default,MaxLength=f.Kind==FieldKind.Number?14:200,HeightRequest=45 };
e.TextChanged+=(_,_)=>Changed?.Invoke();
if(f.Kind==FieldKind.Number&&(!f.Required||f.Key.Contains("Rate",StringComparison.OrdinalIgnoreCase)))
e.Unfocused+=(_,_)=>{if(string.IsNullOrWhiteSpace(e.Text))e.Text="0";};
input=e;getters[f.Key]=validate=> {if(f.Kind!=FieldKind.Number)return e.Text?.Trim()??"";if(string.IsNullOrWhiteSpace(e.Text)&&!f.Required)return null;if(!decimal.TryParse(e.Text,NumberStyles.Number,CultureInfo.InvariantCulture,out var n)){if(validate)throw new ArgumentException(f.Label+"请填写数字");return e.Text;}return n;};break;
case FieldKind.Date:
var d=new DatePicker {Date=initial.Text(f.Key).Length>0?initial.Date(f.Key):null,Format="yyyy-MM-dd",HeightRequest=45};input=d;getters[f.Key]=_=>d.Date.HasValue?Display.Day(d.Date.Value):null;break;
case FieldKind.Choice:
var choices=f.Choices??new();var keys=choices.Keys.ToArray();var picker=new Picker {Title="请选择"+f.Label,ItemsSource=choices.Values.ToList(),HeightRequest=45};
picker.SelectedIndex=Array.IndexOf(keys,initial.Text(f.Key));picker.SelectedIndexChanged+=(_,_)=>Changed?.Invoke();input=picker;getters[f.Key]=_=>picker.SelectedIndex>=0?choices.Keys.ElementAtOrDefault(picker.SelectedIndex)??"":"";break;
case FieldKind.SearchChoice:
var select=new SearchChoiceInput(f.Label,f.Choices??new(),initial.Text(f.Key));select.Changed+=()=>Changed?.Invoke();input=select;getters[f.Key]=_=>select.Value;break;
case FieldKind.Toggle:
var toggle=new Switch {IsToggled=initial.Int(f.Key)==1,OnColor=Ui.Bronze,HorizontalOptions=LayoutOptions.Start};input=toggle;getters[f.Key]=_=>toggle.IsToggled?1:0;break;
case FieldKind.Notes:
var editor=new Editor {Text=initial.Text(f.Key),Placeholder=f.Hint??"填写"+f.Label,AutoSize=EditorAutoSizeOption.TextChanges,MinimumHeightRequest=80,MaxLength=10000};input=editor;getters[f.Key]=_=>editor.Text?.Trim()??"";break;
case FieldKind.Photos:
var photos=new PhotoField(initial.Text(f.Key),f.Key=="adPhotos"?1:20,f.Label);Photos[f.Key]=photos;input=photos.View;getters[f.Key]=_=>photos.Value;break;
default:throw new ArgumentOutOfRangeException();
}
Inputs[f.Key]=input;
group.Add(f.Kind is FieldKind.Toggle or FieldKind.Photos or FieldKind.SearchChoice?input:Ui.Card(input,12,new Thickness(12,0),Ui.Paper));
View.Add(group);
if(f.Required) {var getter=getters[f.Key];getters[f.Key]=validate=> {var value=getter(validate);if(validate && (value is null || string.IsNullOrWhiteSpace(Convert.ToString(value,CultureInfo.InvariantCulture))))throw new ArgumentException("请填写"+f.Label);return value;};}
}
public DbRow Read(bool validate=true) {var row=new DbRow(initial);foreach(var p in getters){if(groups.TryGetValue(p.Key,out var group)&&!group.IsVisible)row.Remove(p.Key);else row[p.Key]=p.Value(validate);}return row;}
public void Computed(string key,Func<object?> getter)=>getters[key]=_=>getter();
public void SetLabel(string key,string text) {if(labels.TryGetValue(key,out var label))label.Text=text;}
public void SetVisible(string key,bool visible) {if(groups.TryGetValue(key,out var group))group.IsVisible=visible;}
}
public static class MediaFiles
{
public static ImageSource Source(string value)
{
if(value.StartsWith("data:image/",StringComparison.OrdinalIgnoreCase)) {var bytes=Convert.FromBase64String(value[(value.IndexOf(',')+1)..]);return ImageSource.FromStream(()=>new MemoryStream(bytes));}
return ImageSource.FromFile(value);
}
public static List<string> Parse(string value)
{
if(string.IsNullOrWhiteSpace(value))return [];
try {return JsonSerializer.Deserialize<List<string>>(value)??[];}catch(JsonException){return [value];}
}
public static async Task<string?> Pick(bool camera)
{
FileResult? result;var account=Services.AuthClient.Current.AccountId;
using(new Services.ExternalPhotoScope())
{
#if ANDROID
if(camera&&await Permissions.RequestAsync<Permissions.Camera>()!=PermissionStatus.Granted)throw new InvalidOperationException("请允许使用相机后重试");
result=await MediaPhotoActivity.Pick(camera);
#else
result=camera?await MediaPicker.Default.CapturePhotoAsync():await FilePicker.Default.PickAsync(new PickOptions {PickerTitle="选择照片",FileTypes=FilePickerFileType.Images});
#endif
}
if(result is null)return null;
try{return account==Services.AuthClient.Current.AccountId?await ReadPhoto(result,false):null;}
finally
{
#if ANDROID
File.Delete(result.FullPath);
#endif
}
}
public static async Task<string> ReadPhoto(FileResult result,bool camera)
{
byte[] bytes;
try{await using var stream=await result.OpenReadAsync();using var buffer=new MemoryStream();await stream.CopyToAsync(buffer);bytes=buffer.ToArray();}
finally
{
// CapturePhoto creates an app cache copy. Keep external originals selected by the user.
if(camera&&!string.IsNullOrWhiteSpace(result.FullPath)&&Path.IsPathRooted(result.FullPath))
{
var cache=Path.GetFullPath(FileSystem.CacheDirectory)+Path.DirectorySeparatorChar;var captured=Path.GetFullPath(result.FullPath);
if(captured.StartsWith(cache,StringComparison.Ordinal))File.Delete(captured);
}
}
#if ANDROID
using var bounds=new Android.Graphics.BitmapFactory.Options {InJustDecodeBounds=true};
Android.Graphics.BitmapFactory.DecodeByteArray(bytes,0,bytes.Length,bounds);
if(bounds.OutWidth<1||bounds.OutHeight<1||(long)bounds.OutWidth*bounds.OutHeight>100_000_000)throw new ArgumentException("照片尺寸无效或过大");
int sample=1;while(Math.Max(bounds.OutWidth,bounds.OutHeight)/sample>1600)sample*=2;
using var opts=new Android.Graphics.BitmapFactory.Options {InSampleSize=sample};
using var bitmap=Android.Graphics.BitmapFactory.DecodeByteArray(bytes,0,bytes.Length,opts)??throw new InvalidOperationException("无法读取图片");
// Correct EXIF orientation before compression discards it, so the source watermark stays at the visible top right.
int orientation=1;try{using var metadata=new MemoryStream(bytes);using var exif=new AndroidX.ExifInterface.Media.ExifInterface(metadata);orientation=exif.GetAttributeInt(AndroidX.ExifInterface.Media.ExifInterface.TagOrientation,1);}catch(Java.IO.IOException){}
using var jpeg=new MemoryStream();
if(orientation is >=2 and <=8)
{
using var matrix=new Android.Graphics.Matrix();matrix.SetValues(orientation switch
{
2=>[-1,0,0,0,1,0,0,0,1],3=>[-1,0,0,0,-1,0,0,0,1],4=>[1,0,0,0,-1,0,0,0,1],
5=>[0,1,0,1,0,0,0,0,1],6=>[0,-1,0,1,0,0,0,0,1],7=>[0,-1,0,-1,0,0,0,0,1],_=>[0,1,0,-1,0,0,0,0,1]
});
using var upright=Android.Graphics.Bitmap.CreateBitmap(bitmap,0,0,bitmap.Width,bitmap.Height,matrix,true)!;upright.Compress(Android.Graphics.Bitmap.CompressFormat.Jpeg!,82,jpeg);
}
else bitmap.Compress(Android.Graphics.Bitmap.CompressFormat.Jpeg!,82,jpeg);bytes=jpeg.ToArray();
#endif
return "data:image/jpeg;base64,"+Convert.ToBase64String(bytes);
}
}
public sealed class PhotoField
{
private readonly List<string> images;
private readonly HorizontalStackLayout strip=new() {Spacing=8};
private readonly int limit;
private readonly string label;
public ScrollView View {get;}
public string Value=>JsonSerializer.Serialize(images);
public PhotoField(string value,int limit=20,string label="照片") {this.limit=limit;this.label=label;images=MediaFiles.Parse(value);View=new ScrollView {Content=strip,Orientation=ScrollOrientation.Horizontal,HorizontalScrollBarVisibility=ScrollBarVisibility.Never};Render();}
private void Render()
{
strip.Clear();
foreach(var p in images.ToArray())
{
var img=new Image {Source=MediaFiles.Source(p),Aspect=Aspect.AspectFill,WidthRequest=57,HeightRequest=65};
strip.Add(Ui.Tap(Ui.Card(img,9,new Thickness(0)),async()=> {var page=Application.Current!.Windows[0].Page!;if(await page.DisplayAlertAsync("移除照片","从当前记录移除此照片?","移除","保留")){images.Remove(p);Render();}}));
}
var add=Ui.Card(Ui.Icon("plus",Ui.Bronze,23),10,new Thickness(17),Ui.Paper);
strip.Add(Ui.Tap(add,async()=> {if(images.Count>=limit)throw new ArgumentException("此处最多添加 "+limit+" 张照片,请先移除已有照片");
#if !COMMUNITY_APP
var form=(Application.Current!.Windows[0].Page as NavigationPage)?.CurrentPage as FormPage;form?.PreserveDraft();
#endif
var action=await Application.Current!.Windows[0].Page!.DisplayActionSheetAsync("添加照片","取消",null,"拍照","相册");if(action is not ("拍照" or "相册"))return;
#if COMMUNITY_APP
var p=await MediaFiles.Pick(action=="拍照");
#else
var draft=action=="拍照"?form?.CameraDraft?.Invoke(this):null;var p=draft is null?await MediaFiles.Pick(action=="拍照"):await Services.OrderCameraRecovery.Capture(draft);
#endif
if(p is not null) {images.Add(p);Render();
#if !COMMUNITY_APP
form?.PreserveDraft();
#endif
}},"添加"+label));
}
}