XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

STHApplication

【MAUI】寄当家——安卓端交易管理系统

公开
关注 0 Fork 0 Star 0
UTF-8
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));
    }
}