mirror of
https://github.com/awatertrevi/xamarin-neo4j.git
synced 2026-09-22 09:05:29 +00:00
.NET MAUI upgrade
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Xamarin.Neo4j\Xamarin.Neo4j.csproj" />
|
||||
<Compile Include="..\Xamarin.Neo4j\Utilities\QueryHelper.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.Hosting;
|
||||
|
||||
namespace Xamarin.Neo4j.iOS
|
||||
{
|
||||
// The UIApplicationDelegate for the application. This class is responsible for launching the
|
||||
// User Interface of the application, as well as listening (and optionally responding) to
|
||||
// application events from iOS.
|
||||
[Register("AppDelegate")]
|
||||
public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
//
|
||||
// This method is invoked when the application has loaded and is ready to run. In this
|
||||
// method you should instantiate the window, load the UI into it and then make the window
|
||||
// visible.
|
||||
//
|
||||
// You have 17 seconds to return from this method, or iOS will terminate your application.
|
||||
//
|
||||
public override bool FinishedLaunching(UIApplication app, NSDictionary options)
|
||||
{
|
||||
Forms.Forms.Init();
|
||||
|
||||
LoadApplication(new App());
|
||||
|
||||
return base.FinishedLaunching(app, options);
|
||||
}
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//
|
||||
// QueryEditorHandler.cs
|
||||
//
|
||||
// Trevi Awater
|
||||
// 13-01-2022
|
||||
//
|
||||
// © Xamarin.Neo4j.iOS
|
||||
//
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using CoreGraphics;
|
||||
using Foundation;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Handlers;
|
||||
using Microsoft.Maui.Platform;
|
||||
using UIKit;
|
||||
using Xamarin.Neo4j.Controls;
|
||||
|
||||
namespace Xamarin.Neo4j.iOS.CustomRenderers
|
||||
{
|
||||
public class QueryEditorHandler : EditorHandler
|
||||
{
|
||||
private readonly string[] _keyWords =
|
||||
[
|
||||
// Clauses
|
||||
"CALL", "CREATE", "DELETE", "DETACH", "FOREACH", "LOAD", "MATCH", "MERGE", "OPTIONAL", "REMOVE", "RETURN", "SET", "START", "UNION", "UNWIND", "WITH",
|
||||
|
||||
// Subclauses
|
||||
"LIMIT", "ORDER", "SKIP", "WHERE", "YIELD",
|
||||
|
||||
// Modifiers
|
||||
"ASC", "ASCENDING", "ASSERT", "BY", "CSV", "DESC", "DESCENDING", "ON",
|
||||
|
||||
// Expressions
|
||||
"ALL", "CASE", "COUNT", "ELSE", "END", "EXISTS", "THEN", "WHEN",
|
||||
|
||||
// Operators
|
||||
"AND", "AS", "CONTAINS", "DISTINCT", "ENDS", "IN", "IS", "NOT", "OR", "STARTS", "XOR",
|
||||
|
||||
// Schema
|
||||
"CONSTRAINT", "CREATE", "DROP", "EXISTS", "INDEX", "NODE", "KEY", "UNIQUE",
|
||||
|
||||
// Hints
|
||||
"INDEX", "JOIN", "SCAN", "USING",
|
||||
|
||||
// Literals
|
||||
"FALSE", "NULL", "TRUE"
|
||||
];
|
||||
|
||||
protected override void ConnectHandler(MauiTextView platformView)
|
||||
{
|
||||
base.ConnectHandler(platformView);
|
||||
|
||||
platformView.AutocorrectionType = UITextAutocorrectionType.No;
|
||||
platformView.AutocapitalizationType = UITextAutocapitalizationType.None;
|
||||
platformView.SpellCheckingType = UITextSpellCheckingType.No;
|
||||
platformView.KeyboardType = UIKeyboardType.Default;
|
||||
|
||||
const float accessoryHeight = 58f;
|
||||
const float buttonWidth = 33f;
|
||||
const float executeButtonWidth = 90f;
|
||||
const float padding = 8f;
|
||||
const float pillHeight = 38f;
|
||||
|
||||
var keys = new[]
|
||||
{
|
||||
("(", "("), (")", ")"), ("[", "["), ("]", "]"),
|
||||
(":", ":"), ("-", "-"), ("\u2192", "->"), ("\u2190", "<-")
|
||||
};
|
||||
|
||||
// Outer accessory — clear so system background shows through
|
||||
var accessoryView = new UIView(new CGRect(0, 0, 0, accessoryHeight));
|
||||
accessoryView.AutoresizingMask = UIViewAutoresizing.FlexibleWidth;
|
||||
accessoryView.BackgroundColor = UIColor.Clear;
|
||||
|
||||
// Pill container for scroll view
|
||||
var pillContainer = new UIView();
|
||||
pillContainer.TranslatesAutoresizingMaskIntoConstraints = false;
|
||||
pillContainer.BackgroundColor = UIColor.SystemBackground.ColorWithAlpha(0.9f);
|
||||
pillContainer.Layer.CornerRadius = pillHeight / 2f;
|
||||
pillContainer.Layer.MasksToBounds = true;
|
||||
|
||||
var scrollView = new UIScrollView();
|
||||
scrollView.TranslatesAutoresizingMaskIntoConstraints = false;
|
||||
scrollView.ShowsHorizontalScrollIndicator = false;
|
||||
scrollView.ShowsVerticalScrollIndicator = false;
|
||||
scrollView.Bounces = false;
|
||||
scrollView.BackgroundColor = UIColor.Clear;
|
||||
|
||||
var xOffset = 0f;
|
||||
foreach (var (label, insertion) in keys)
|
||||
{
|
||||
var captured = insertion;
|
||||
var btn = new UIButton(UIButtonType.System);
|
||||
btn.SetTitle(label, UIControlState.Normal);
|
||||
btn.Frame = new CGRect(xOffset, 0, buttonWidth, pillHeight);
|
||||
btn.TouchUpInside += (s, e) => platformView.InsertText(captured);
|
||||
scrollView.AddSubview(btn);
|
||||
xOffset += buttonWidth;
|
||||
}
|
||||
|
||||
scrollView.ContentSize = new CGSize(xOffset, pillHeight);
|
||||
pillContainer.AddSubview(scrollView);
|
||||
|
||||
NSLayoutConstraint.ActivateConstraints(new[]
|
||||
{
|
||||
scrollView.LeadingAnchor.ConstraintEqualTo(pillContainer.LeadingAnchor),
|
||||
scrollView.TrailingAnchor.ConstraintEqualTo(pillContainer.TrailingAnchor),
|
||||
scrollView.TopAnchor.ConstraintEqualTo(pillContainer.TopAnchor),
|
||||
scrollView.BottomAnchor.ConstraintEqualTo(pillContainer.BottomAnchor),
|
||||
});
|
||||
|
||||
// Execute button — blue pill
|
||||
var executeBtn = new UIButton(UIButtonType.System);
|
||||
executeBtn.TranslatesAutoresizingMaskIntoConstraints = false;
|
||||
executeBtn.SetTitle("Execute", UIControlState.Normal);
|
||||
executeBtn.SetTitleColor(UIColor.White, UIControlState.Normal);
|
||||
executeBtn.BackgroundColor = UIColor.SystemBlue;
|
||||
executeBtn.Layer.CornerRadius = pillHeight / 2f;
|
||||
executeBtn.Layer.MasksToBounds = true;
|
||||
executeBtn.TouchUpInside += (s, e) =>
|
||||
{
|
||||
if (VirtualView is QueryEditor queryEditor)
|
||||
{
|
||||
queryEditor.RaiseExecuteClicked();
|
||||
platformView.ResignFirstResponder();
|
||||
}
|
||||
};
|
||||
|
||||
accessoryView.AddSubview(pillContainer);
|
||||
accessoryView.AddSubview(executeBtn);
|
||||
|
||||
NSLayoutConstraint.ActivateConstraints(new[]
|
||||
{
|
||||
executeBtn.TrailingAnchor.ConstraintEqualTo(accessoryView.TrailingAnchor, -padding),
|
||||
executeBtn.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor),
|
||||
executeBtn.HeightAnchor.ConstraintEqualTo(pillHeight),
|
||||
executeBtn.WidthAnchor.ConstraintEqualTo(executeButtonWidth),
|
||||
|
||||
pillContainer.LeadingAnchor.ConstraintEqualTo(accessoryView.LeadingAnchor, padding),
|
||||
pillContainer.TrailingAnchor.ConstraintEqualTo(executeBtn.LeadingAnchor, -padding),
|
||||
pillContainer.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor),
|
||||
pillContainer.HeightAnchor.ConstraintEqualTo(pillHeight),
|
||||
});
|
||||
|
||||
platformView.InputAccessoryView = accessoryView;
|
||||
|
||||
platformView.Changed += (s, e) => HighlightWords(platformView, _keyWords);
|
||||
|
||||
HighlightWords(platformView, _keyWords);
|
||||
}
|
||||
|
||||
private static void HighlightWords(UITextView platformView, IEnumerable<string> wordsToHighlight)
|
||||
{
|
||||
var text = PreprocessText(platformView.Text ?? string.Empty);
|
||||
var attributedText = new NSMutableAttributedString(text);
|
||||
|
||||
foreach (var word in wordsToHighlight)
|
||||
{
|
||||
var regex = new Regex("\\b" + Regex.Escape(word) + "\\b", RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match match in regex.Matches(text))
|
||||
{
|
||||
attributedText.AddAttribute(UIStringAttributeKey.ForegroundColor,
|
||||
UIColor.FromRGBA(137 / 255f, 152 / 255f, 46 / 255f, 1f),
|
||||
new NSRange(match.Index, match.Length));
|
||||
}
|
||||
}
|
||||
|
||||
ApplyQuoteTextColorFormatting(text, attributedText, "'(.*?)'",
|
||||
UIColor.FromRGBA(174 / 255f, 139 / 255f, 45 / 255f, 1f));
|
||||
|
||||
ApplyQuoteTextColorFormatting(text, attributedText, "\"(.*?)\"",
|
||||
UIColor.FromRGBA(174 / 255f, 139 / 255f, 45 / 255f, 1f));
|
||||
|
||||
var cursorPosition = platformView.SelectedRange;
|
||||
platformView.AttributedText = attributedText;
|
||||
platformView.SelectedRange = cursorPosition;
|
||||
}
|
||||
|
||||
private static void ApplyQuoteTextColorFormatting(string text, NSMutableAttributedString attributedText,
|
||||
string quotePattern, UIColor color)
|
||||
{
|
||||
var quoteRegex = new Regex(quotePattern);
|
||||
|
||||
foreach (Match match in quoteRegex.Matches(text))
|
||||
{
|
||||
attributedText.AddAttribute(UIStringAttributeKey.ForegroundColor, color,
|
||||
new NSRange(match.Index, match.Length));
|
||||
}
|
||||
}
|
||||
|
||||
private static string PreprocessText(string text)
|
||||
{
|
||||
text = text.Replace("\u2018", "'");
|
||||
text = text.Replace("\u2019", "'");
|
||||
text = text.Replace("\u201c", "\"");
|
||||
text = text.Replace("\u201d", "\"");
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
//
|
||||
// QueryEditorRenderer.cs
|
||||
//
|
||||
// Trevi Awater
|
||||
// 13-01-2022
|
||||
//
|
||||
// © Xamarin.Neo4j.iOS
|
||||
//
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using CoreGraphics;
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Platform.iOS;
|
||||
using Xamarin.Neo4j.Controls;
|
||||
using Xamarin.Neo4j.iOS.CustomRenderers;
|
||||
|
||||
[assembly: ExportRenderer(typeof(QueryEditor), typeof(QueryEditorRenderer))]
|
||||
namespace Xamarin.Neo4j.iOS.CustomRenderers
|
||||
{
|
||||
public class QueryEditorRenderer : EditorRenderer
|
||||
{
|
||||
private readonly string[] _keyWords =
|
||||
{
|
||||
// Clauses
|
||||
"CALL", "CREATE", "DELETE", "DETACH", "FOREACH", "LOAD", "MATCH", "MERGE", "OPTIONAL", "REMOVE", "RETURN", "SET", "START", "UNION", "UNWIND", "WITH",
|
||||
|
||||
// Subclauses
|
||||
"LIMIT", "ORDER", "SKIP", "WHERE", "YIELD",
|
||||
|
||||
// Modifiers
|
||||
"ASC", "ASCENDING", "ASSERT", "BY", "CSV", "DESC", "DESCENDING", "ON",
|
||||
|
||||
// Expressions
|
||||
"ALL", "CASE", "COUNT", "ELSE", "END", "EXISTS", "THEN", "WHEN",
|
||||
|
||||
// Operators
|
||||
"AND", "AS", "CONTAINS", "DISTINCT", "ENDS", "IN", "IS", "NOT", "OR", "STARTS", "XOR",
|
||||
|
||||
// Schema
|
||||
"CONSTRAINT", "CREATE", "DROP", "EXISTS", "INDEX", "NODE", "KEY", "UNIQUE",
|
||||
|
||||
// Hints
|
||||
"INDEX", "JOIN", "SCAN", "USING",
|
||||
|
||||
// Literals
|
||||
"FALSE", "NULL", "TRUE"
|
||||
};
|
||||
|
||||
protected override void OnElementChanged(ElementChangedEventArgs<Editor> e)
|
||||
{
|
||||
base.OnElementChanged(e);
|
||||
|
||||
if (e.NewElement != null)
|
||||
{
|
||||
Element.TextChanged += (sender, args) =>
|
||||
{
|
||||
HighlightWords(_keyWords);
|
||||
};
|
||||
}
|
||||
|
||||
if (Control != null && Element != null)
|
||||
{
|
||||
Control.AutocorrectionType = UITextAutocorrectionType.No;
|
||||
Control.AutocapitalizationType = UITextAutocapitalizationType.None;
|
||||
Control.SpellCheckingType = UITextSpellCheckingType.No;
|
||||
Control.KeyboardType = UIKeyboardType.Default;
|
||||
|
||||
var executeButton = new UIBarButtonItem("Execute", UIBarButtonItemStyle.Done, (sender, args) =>
|
||||
{
|
||||
if (Element is QueryEditor queryEditor)
|
||||
{
|
||||
queryEditor.RaiseExecuteClicked();
|
||||
|
||||
Control.ResignFirstResponder();
|
||||
}
|
||||
});
|
||||
|
||||
var keyButtons = new[]
|
||||
{
|
||||
new UIBarButtonItem("(", UIBarButtonItemStyle.Plain, (sender, args) => InsertText("(")),
|
||||
new UIBarButtonItem(")", UIBarButtonItemStyle.Plain, (sender, args) => InsertText(")")),
|
||||
new UIBarButtonItem("[", UIBarButtonItemStyle.Plain, (sender, args) => InsertText("[")),
|
||||
new UIBarButtonItem("]", UIBarButtonItemStyle.Plain, (sender, args) => InsertText("]")),
|
||||
new UIBarButtonItem(":", UIBarButtonItemStyle.Plain, (sender, args) => InsertText(":")),
|
||||
new UIBarButtonItem("-", UIBarButtonItemStyle.Plain, (sender, args) => InsertText("-")),
|
||||
new UIBarButtonItem("\u2192", UIBarButtonItemStyle.Plain, (sender, args) => InsertText("->")),
|
||||
new UIBarButtonItem("\u2190", UIBarButtonItemStyle.Plain, (sender, args) => InsertText("<-")),
|
||||
|
||||
new UIBarButtonItem(UIBarButtonSystemItem.FlexibleSpace),
|
||||
|
||||
executeButton
|
||||
};
|
||||
|
||||
var toolbar = new UIToolbar(new CGRect(0.0f, 0.0f, Control.Frame.Size.Width, 44.0f));
|
||||
toolbar.Items = keyButtons;
|
||||
|
||||
Control.InputAccessoryView = toolbar;
|
||||
|
||||
HighlightWords(_keyWords);
|
||||
}
|
||||
}
|
||||
|
||||
private void HighlightWords(IEnumerable<string> wordsToHighlight)
|
||||
{
|
||||
var text = PreprocessText(Control.Text);
|
||||
var attributedText = new NSMutableAttributedString(text);
|
||||
|
||||
// Iterate through the array and apply formatting to the editor's text.
|
||||
foreach (var word in wordsToHighlight)
|
||||
{
|
||||
var regex = new Regex("\\b" + Regex.Escape(word) + "\\b", RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match match in regex.Matches(text))
|
||||
{
|
||||
attributedText.AddAttribute(UIStringAttributeKey.ForegroundColor, Color.FromHex("#89982e").ToUIColor(),
|
||||
new NSRange(match.Index, match.Length));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply text color formatting for text inside single quotes.
|
||||
ApplyQuoteTextColorFormatting(text, attributedText, "'(.*?)'", Color.FromHex("#ae8b2d").ToUIColor());
|
||||
|
||||
// Apply text color formatting for text inside double quotes.
|
||||
ApplyQuoteTextColorFormatting(text, attributedText, "\"(.*?)\"", Color.FromHex("#ae8b2d").ToUIColor());
|
||||
|
||||
attributedText.AddAttribute(UIStringAttributeKey.Font, UIFont.FromName("Roboto Mono", (nfloat) Element.FontSize), new NSRange(0, attributedText.Length));
|
||||
|
||||
Control.AttributedText = attributedText;
|
||||
}
|
||||
|
||||
private void InsertText(string text)
|
||||
{
|
||||
Control.InsertText(text);
|
||||
}
|
||||
|
||||
private static void ApplyQuoteTextColorFormatting(string text, NSMutableAttributedString attributedText,
|
||||
string quotePattern, UIColor color)
|
||||
{
|
||||
var quoteRegex = new Regex(quotePattern);
|
||||
|
||||
foreach (Match match in quoteRegex.Matches(text))
|
||||
{
|
||||
attributedText.AddAttribute(UIStringAttributeKey.ForegroundColor, color,
|
||||
new NSRange(match.Index, match.Length));
|
||||
}
|
||||
}
|
||||
|
||||
private static string PreprocessText(string text)
|
||||
{
|
||||
// Replace single quotation marks and their curly variants with: '
|
||||
text = text.Replace("‘", "'");
|
||||
text = text.Replace("’", "'");
|
||||
|
||||
// Replace double quotation marks and their curly variants with: "
|
||||
text = text.Replace("“", "\"");
|
||||
text = text.Replace("”", "\"");
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>5</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.1.0</string>
|
||||
<string>2.0.0</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>CFBundleName</key>
|
||||
|
||||
38
Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.iOS/MauiProgram.cs
Normal file
38
Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.iOS/MauiProgram.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui.Controls.Hosting;
|
||||
using Microsoft.Maui.Hosting;
|
||||
using Xamarin.Neo4j.iOS.CustomRenderers;
|
||||
using Xamarin.Neo4j.iOS.Services;
|
||||
using Xamarin.Neo4j.Services;
|
||||
using Xamarin.Neo4j.Services.Interfaces;
|
||||
|
||||
namespace Xamarin.Neo4j.iOS
|
||||
{
|
||||
public static class MauiProgram
|
||||
{
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder
|
||||
.UseMauiApp<App>()
|
||||
.ConfigureFonts(fonts =>
|
||||
{
|
||||
fonts.AddFont("iconize-fontawesome-brands.ttf", "FontAwesome5Brands-Regular");
|
||||
fonts.AddFont("iconize-fontawesome-solid.ttf", "FontAwesome5Free-Solid");
|
||||
fonts.AddFont("iconize-fontawesome-regular.ttf", "FontAwesome5Free-Regular");
|
||||
fonts.AddFont("roboto-mono-regular.ttf", "RobotoMono");
|
||||
})
|
||||
.ConfigureMauiHandlers(handlers =>
|
||||
{
|
||||
handlers.AddHandler<Controls.QueryEditor, QueryEditorHandler>();
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<ITrustManagerService, TrustManagerService>();
|
||||
builder.Services.AddSingleton<IVersionService, VersionService>();
|
||||
builder.Services.AddSingleton<IScreenSizeService, ScreenSizeService>();
|
||||
builder.Services.AddSingleton<Neo4jService>();
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,9 @@
|
||||
// © Xamarin.Neo4j.iOS
|
||||
//
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using UIKit;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Neo4j.iOS.Services;
|
||||
using Xamarin.Neo4j.Services;
|
||||
using Xamarin.Neo4j.Services.Interfaces;
|
||||
|
||||
[assembly: Dependency(typeof(ScreenSizeService))]
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
// © Xamarin.Neo4j.iOS
|
||||
//
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Neo4j.Driver;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Neo4j.iOS.Services;
|
||||
using Xamarin.Neo4j.Services;
|
||||
using Xamarin.Neo4j.Services.Interfaces;
|
||||
|
||||
[assembly: Dependency(typeof(TrustManagerService))]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//
|
||||
|
||||
using Foundation;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Xamarin.Neo4j.iOS.Services;
|
||||
using Xamarin.Neo4j.Services.Interfaces;
|
||||
|
||||
|
||||
@@ -1,158 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">iPhoneSimulator</Platform>
|
||||
<ProductVersion>8.0.30703</ProductVersion>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
<ProjectGuid>{544E1297-4171-4B91-8AF4-4CD5CFD18A04}</ProjectGuid>
|
||||
<ProjectTypeGuids>{FEACFBD2-3405-455C-9665-78FE426C6842};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||
<TargetFramework>net10.0-ios</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>Xamarin.Neo4j.iOS</RootNamespace>
|
||||
<IPhoneResourcePrefix>Resources</IPhoneResourcePrefix>
|
||||
<UseMaui>true</UseMaui>
|
||||
<AssemblyName>Xamarin.Neo4j.iOS</AssemblyName>
|
||||
<ApplicationId>nl.resoftware.pocketgraph</ApplicationId>
|
||||
<ApplicationTitle>PocketGraph</ApplicationTitle>
|
||||
<MtouchHttpClientHandler>NSUrlSessionHandler</MtouchHttpClientHandler>
|
||||
<SupportedOSPlatformVersion>14.2</SupportedOSPlatformVersion>
|
||||
<ValidateXcodeVersion>false</ValidateXcodeVersion>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|iPhoneSimulator' ">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\iPhoneSimulator\Debug</OutputPath>
|
||||
<DefineConstants>DEBUG</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<ConsolePause>false</ConsolePause>
|
||||
<MtouchArch>x86_64</MtouchArch>
|
||||
<MtouchLink>None</MtouchLink>
|
||||
<MtouchDebug>true</MtouchDebug>
|
||||
<CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
|
||||
<CodesignKey>iPhone Developer</CodesignKey>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|iPhoneSimulator' ">
|
||||
<DebugType>none</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\iPhoneSimulator\Release</OutputPath>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<MtouchLink>None</MtouchLink>
|
||||
<MtouchArch>x86_64</MtouchArch>
|
||||
<ConsolePause>false</ConsolePause>
|
||||
<CodesignKey>iPhone Developer</CodesignKey>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|iPhone' ">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\iPhone\Debug</OutputPath>
|
||||
<DefineConstants>DEBUG</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<ConsolePause>false</ConsolePause>
|
||||
<MtouchArch>ARM64</MtouchArch>
|
||||
<CodesignKey>iPhone Developer</CodesignKey>
|
||||
<MtouchDebug>true</MtouchDebug>
|
||||
<CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
|
||||
<MtouchLink>SdkOnly</MtouchLink>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|iPhone' ">
|
||||
<DebugType>none</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\iPhone\Release</OutputPath>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<MtouchArch>ARM64</MtouchArch>
|
||||
<ConsolePause>false</ConsolePause>
|
||||
<CodesignKey>iPhone Developer</CodesignKey>
|
||||
<CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
|
||||
<MtouchLink>SdkOnly</MtouchLink>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Ad-Hoc|iPhone' ">
|
||||
<DebugType>none</DebugType>
|
||||
<Optimize>True</Optimize>
|
||||
<OutputPath>bin\iPhone\Ad-Hoc</OutputPath>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<ConsolePause>False</ConsolePause>
|
||||
<MtouchArch>ARM64</MtouchArch>
|
||||
<BuildIpa>True</BuildIpa>
|
||||
<CodesignProvision>Automatic:AdHoc</CodesignProvision>
|
||||
<CodesignKey>iPhone Distribution</CodesignKey>
|
||||
<CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
|
||||
<MtouchLink>SdkOnly</MtouchLink>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'AppStore|iPhone' ">
|
||||
<DebugType>none</DebugType>
|
||||
<Optimize>True</Optimize>
|
||||
<OutputPath>bin\iPhone\AppStore</OutputPath>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<ConsolePause>False</ConsolePause>
|
||||
<MtouchArch>ARM64</MtouchArch>
|
||||
<CodesignProvision>Automatic:AppStore</CodesignProvision>
|
||||
<CodesignKey>iPhone Distribution</CodesignKey>
|
||||
<CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
|
||||
<MtouchLink>SdkOnly</MtouchLink>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="CustomRenderers\QueryEditorRenderer.cs" />
|
||||
<Compile Include="Main.cs" />
|
||||
<Compile Include="AppDelegate.cs" />
|
||||
<Compile Include="Security\NativeTrustManager.cs" />
|
||||
<Compile Include="Services\ScreenSizeService.cs" />
|
||||
<Compile Include="Services\TrustManagerService.cs" />
|
||||
<Compile Include="Services\VersionService.cs" />
|
||||
<None Include="Entitlements.plist" />
|
||||
<None Include="Info.plist" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.20" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-20x20@1x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-20x20@2x-1.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-20x20@2x-2.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-20x20@2x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-20x20@3x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-29x29@1x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-29x29@2x-1.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-29x29@2x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-29x29@3x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-40x40@2x-1.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-40x40@2x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-40x40@3x-1.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-40x40@3x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-60x60@3x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-76x76@1x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-76x76@2x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-83.5@2x.png" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Icon-marketing-1024x1024.png" />
|
||||
<InterfaceDefinition Include="LaunchScreen.storyboard" />
|
||||
<ImageAsset Include="Assets.xcassets\AppIcon.appiconset\Contents.json">
|
||||
<Visible>false</Visible>
|
||||
</ImageAsset>
|
||||
<Compile Remove="Properties\AssemblyInfo.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="Xamarin.iOS" />
|
||||
<BundleResource Include="Resources\iconize-fontawesome-brands.ttf" />
|
||||
<BundleResource Include="Resources\iconize-fontawesome-regular.ttf" />
|
||||
<BundleResource Include="Resources\iconize-fontawesome-solid.ttf" />
|
||||
<BundleResource Include="Resources\logo.png" />
|
||||
<BundleResource Include="Resources\resoftware.png" />
|
||||
<BundleResource Include="Resources\roboto-mono-regular.ttf" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Xamarin.Forms" Version="5.0.0.2196" />
|
||||
<ProjectReference Include="..\Xamarin.Neo4j\Xamarin.Neo4j.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<BundleResource Include="Resources\iconize-fontawesome-brands.ttf" />
|
||||
<BundleResource Include="Resources\iconize-fontawesome-regular.ttf" />
|
||||
<BundleResource Include="Resources\iconize-fontawesome-solid.ttf" />
|
||||
<BundleResource Include="Resources\logo.png" />
|
||||
<BundleResource Include="Resources\resoftware.png" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<BundleResource Include="Resources\roboto-mono-regular.ttf" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Xamarin.Neo4j\Xamarin.Neo4j.csproj">
|
||||
<Project>{756232d0-dbb0-4aeb-b1cf-1f5ca12cac85}</Project>
|
||||
<Name>Xamarin.Neo4j</Name>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildExtensionsPath)\Xamarin\iOS\Xamarin.iOS.CSharp.targets" />
|
||||
</Project>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Application xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Xamarin.Neo4j.App">
|
||||
</Application>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Xamarin.Neo4j.Pages;
|
||||
using Xamarin.Neo4j.Themes;
|
||||
|
||||
@@ -19,32 +19,27 @@ namespace Xamarin.Neo4j
|
||||
MainPage = new NavigationPage(new RootPage());
|
||||
}
|
||||
|
||||
|
||||
private void SetTheme(OSAppTheme theme)
|
||||
private void SetTheme(AppTheme theme)
|
||||
{
|
||||
Resources = theme switch
|
||||
{
|
||||
OSAppTheme.Dark => new DarkTheme(),
|
||||
OSAppTheme.Light => new LightTheme(),
|
||||
AppTheme.Dark => new DarkTheme(),
|
||||
AppTheme.Light => new LightTheme(),
|
||||
|
||||
_ => new LightTheme()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
protected override void OnStart()
|
||||
{
|
||||
// Handle when your app starts
|
||||
}
|
||||
|
||||
protected override void OnSleep()
|
||||
{
|
||||
// Handle when your app sleeps
|
||||
}
|
||||
|
||||
protected override void OnResume()
|
||||
{
|
||||
// Handle when your app resumes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ViewCell xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
x:Class="Xamarin.Neo4j.Controls.ConnectionCell">
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
|
||||
namespace Xamarin.Neo4j.Controls
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ViewCell xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="Xamarin.Neo4j.Controls.LicenseCell">
|
||||
<ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="Xamarin.Neo4j.Controls.LicenseCell">
|
||||
<StackLayout Padding="15, 15, 15, 0">
|
||||
<Label FontAttributes="Bold" TextColor="#4169FF" Text="{Binding Name}">
|
||||
<Label.GestureRecognizers>
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
|
||||
namespace Xamarin.Neo4j.Controls
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//
|
||||
|
||||
using System;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.Controls;
|
||||
|
||||
namespace Xamarin.Neo4j.Controls
|
||||
{
|
||||
@@ -17,19 +17,7 @@ namespace Xamarin.Neo4j.Controls
|
||||
public double MaxHeight { get; set; }
|
||||
|
||||
public event EventHandler ExecuteClicked;
|
||||
|
||||
protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)
|
||||
{
|
||||
var sizeRequest = base.OnMeasure(widthConstraint, heightConstraint);
|
||||
|
||||
var newHeight = sizeRequest.Request.Height;
|
||||
|
||||
if (newHeight > MaxHeight)
|
||||
newHeight = MaxHeight;
|
||||
|
||||
return new SizeRequest(new Size(sizeRequest.Request.Width, newHeight));
|
||||
}
|
||||
|
||||
public void RaiseExecuteClicked()
|
||||
{
|
||||
ExecuteClicked?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ContentView xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
xmlns:pancakeView="clr-namespace:Xamarin.Forms.PancakeView;assembly=Xamarin.Forms.PancakeView"
|
||||
Margin="0, 0, 0, 25"
|
||||
x:Class="Xamarin.Neo4j.Controls.QueryResultView">
|
||||
<StackLayout Orientation="Vertical" Spacing="0">
|
||||
<pancakeView:PancakeView>
|
||||
<pancakeView:PancakeView.Border>
|
||||
<pancakeView:Border Thickness="4" Color="{StaticResource QueryTextBorder}" />
|
||||
</pancakeView:PancakeView.Border>
|
||||
<Border Stroke="{StaticResource QueryTextBorder}" StrokeThickness="4">
|
||||
<Label Text="{Binding Query}" Padding="8" HorizontalOptions="FillAndExpand" BackgroundColor="{StaticResource Extreme}" />
|
||||
</pancakeView:PancakeView>
|
||||
</Border>
|
||||
|
||||
<WebView x:Name="graphView" HorizontalOptions="FillAndExpand" IsVisible="{Binding CanDisplayGraph}" HeightRequest="200" />
|
||||
|
||||
|
||||
@@ -3,15 +3,18 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Acr.UserDialogs;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui;
|
||||
using Neo4j.Driver;
|
||||
using Xamarin.Neo4j.Managers;
|
||||
using Xamarin.Neo4j.Services;
|
||||
using Xamarin.Neo4j.Models;
|
||||
using Xamarin.Neo4j.Pages;
|
||||
using Xamarin.Neo4j.Utilities;
|
||||
using Query = Xamarin.Neo4j.Models.Query;
|
||||
|
||||
namespace Xamarin.Neo4j.Controls
|
||||
{
|
||||
@@ -27,65 +30,128 @@ namespace Xamarin.Neo4j.Controls
|
||||
public QueryResultView()
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContextChanged += OnBindingContextChanged;
|
||||
graphView.Navigating += OnGraphViewNavigating;
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(string propertyName = null)
|
||||
private async void OnGraphViewNavigating(object sender, WebNavigatingEventArgs e)
|
||||
{
|
||||
base.OnPropertyChanged(propertyName);
|
||||
Console.WriteLine($"[Graph] Inline Navigating: {e.Url}");
|
||||
|
||||
if (propertyName == nameof(BindingContext))
|
||||
if (!e.Url.Contains("expand") || !e.Url.Contains("nodeId")) return;
|
||||
|
||||
e.Cancel = true;
|
||||
|
||||
var connectionString = ConnectionStringManager.ActiveConnectionString;
|
||||
if (connectionString == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
ParseNeovisHtml();
|
||||
var neo4jService = IPlatformApplication.Current.Services.GetRequiredService<Neo4jService>();
|
||||
|
||||
graphView.Source = new HtmlWebViewSource()
|
||||
{
|
||||
Html = _neovisHtml
|
||||
};
|
||||
var match = System.Text.RegularExpressions.Regex.Match(e.Url, @"nodeId=(\d+)");
|
||||
if (!match.Success || !long.TryParse(match.Groups[1].Value, out var nodeId)) return;
|
||||
|
||||
var result = await neo4jService.ExpandNode(nodeId, connectionString);
|
||||
|
||||
if (!result.Success || result.Results == null) return;
|
||||
|
||||
var (nodesJson, edgesJson) = GraphDataHelper.BuildJson(result.Results, connectionString.Id);
|
||||
|
||||
var js = $"window.addGraphData({nodesJson}, {edgesJson}, {nodeId})";
|
||||
await graphView.EvaluateJavaScriptAsync(js);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Graph] Inline expand failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBindingContextChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (BindingContext == null || graphView == null)
|
||||
{
|
||||
Console.WriteLine($"[Graph] OnBindingContextChanged skipped: BindingContext={BindingContext}, graphView={graphView}");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[Graph] OnBindingContextChanged fired, CanDisplayGraph={QueryResult?.CanDisplayGraph}");
|
||||
|
||||
ParseNeovisHtmlSafe();
|
||||
|
||||
Console.WriteLine($"[Graph] Setting graphView.Source, html length={_neovisHtml?.Length ?? 0}");
|
||||
|
||||
graphView.Source = new HtmlWebViewSource { Html = _neovisHtml };
|
||||
}
|
||||
|
||||
private void ParseNeovisHtml()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceName = "Xamarin.Neo4j.Visualization.neovis.html";
|
||||
var resourceName = "Xamarin.Neo4j.Visualization.visgraph.html";
|
||||
|
||||
using (var stream = assembly.GetManifestResourceStream(resourceName))
|
||||
var available = string.Join("\n", assembly.GetManifestResourceNames());
|
||||
Console.WriteLine($"[Graph] Looking for: {resourceName}");
|
||||
Console.WriteLine($"[Graph] Available resources: {available.Replace("\n", ", ")}");
|
||||
|
||||
var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream == null)
|
||||
{
|
||||
_neovisHtml = $"<html><body style='background:#111;color:#f55;font-family:monospace;padding:16px'>" +
|
||||
$"<b>Resource not found:</b><br>{resourceName}<br><br>" +
|
||||
$"<b>Available:</b><br>{available.Replace("\n", "<br>")}</body></html>";
|
||||
return;
|
||||
}
|
||||
|
||||
using (stream)
|
||||
using (var reader = new StreamReader(stream))
|
||||
{
|
||||
var (url, isEncrypted, ignoreTrust) = QueryResult.ConnectionString.ParseHost();
|
||||
|
||||
var result = reader.ReadToEnd();
|
||||
var connectionId = ConnectionStringManager.ActiveConnectionString?.Id ?? Guid.Empty;
|
||||
|
||||
result = result.Replace("{{host}}", url);
|
||||
result = result.Replace("{{database}}", QueryResult.ConnectionString.Database);
|
||||
result = result.Replace("{{username}}", QueryResult.ConnectionString.Username);
|
||||
result = result.Replace("{{password}}", QueryResult.ConnectionString.Password);
|
||||
result = result.Replace("{{encryption}}", isEncrypted ? "ENCRYPTION_ON" : "ENCRYPTION_OFF");
|
||||
result = result.Replace("{{trust}}", ignoreTrust ? "TRUST_ALL_CERTIFICATES" : "TRUST_SYSTEM_CA_SIGNED_CERTIFICATES");
|
||||
result = result.Replace("{{query}}", QueryResult.DisplayQuery);
|
||||
result = result.Replace("{{backgroundColor}}", App.Current.RequestedTheme == OSAppTheme.Dark ? "#292C31" : "#FFFFFF");
|
||||
var (nodesJson, edgesJson) = GraphDataHelper.BuildJson(QueryResult.Results, connectionId);
|
||||
|
||||
var isDark = Application.Current.RequestedTheme == AppTheme.Dark;
|
||||
result = result.Replace("{{nodes}}", nodesJson);
|
||||
result = result.Replace("{{edges}}", edgesJson);
|
||||
result = result.Replace("{{backgroundColor}}", isDark ? "#292C31" : "#FFFFFF");
|
||||
result = result.Replace("{{textColor}}", isDark ? "#FFFFFF" : "#000000");
|
||||
|
||||
_neovisHtml = result;
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseNeovisHtmlSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
ParseNeovisHtml();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Graph] ParseNeovisHtml threw: {ex.GetType().Name}: {ex.Message}");
|
||||
_neovisHtml = $"<html><body style='background:#111;color:#f55;font-family:monospace;padding:16px'>" +
|
||||
$"<b>{ex.GetType().Name}</b><br>{ex.Message}<br><br>{ex.StackTrace?.Replace("\n", "<br>")}</body></html>";
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenNeovis(object sender, EventArgs e)
|
||||
{
|
||||
await Application.Current.MainPage.Navigation.PushAsync(new GraphPage(_neovisHtml));
|
||||
var neo4jService = IPlatformApplication.Current.Services.GetRequiredService<Neo4jService>();
|
||||
var connectionString = ConnectionStringManager.ActiveConnectionString;
|
||||
await Application.Current.MainPage.Navigation.PushAsync(new GraphPage(_neovisHtml, connectionString, neo4jService));
|
||||
}
|
||||
|
||||
private async void SaveQuery(object sender, EventArgs e)
|
||||
{
|
||||
var queryNameResult = await UserDialogs.Instance.PromptAsync("How should the query be called?");
|
||||
var name = await Application.Current.MainPage.DisplayPromptAsync("Save Query", "How should the query be called?");
|
||||
|
||||
if (queryNameResult.Ok)
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var query = new Query()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueryText = QueryResult.Query,
|
||||
Name = queryNameResult.Value,
|
||||
Name = name,
|
||||
};
|
||||
|
||||
SavedQueryManager.AddSavedQuery(query);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Xamarin.Neo4j.Managers;
|
||||
using Xamarin.Neo4j.Models;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.Controls;
|
||||
|
||||
namespace Xamarin.Neo4j.Converters
|
||||
{
|
||||
|
||||
@@ -12,8 +12,8 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices.ComTypes;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.Storage;
|
||||
using Newtonsoft.Json;
|
||||
using Xamarin.Essentials;
|
||||
using Xamarin.Neo4j.Models;
|
||||
|
||||
namespace Xamarin.Neo4j.Managers
|
||||
@@ -26,7 +26,7 @@ namespace Xamarin.Neo4j.Managers
|
||||
|
||||
public static async Task<List<Neo4jConnectionString>> GetConnectionStrings()
|
||||
{
|
||||
var json = await SecureStorage.GetAsync(ConnectionStringsKey);
|
||||
var json = await SecureStorage.Default.GetAsync(ConnectionStringsKey);
|
||||
|
||||
if (string.IsNullOrEmpty(json))
|
||||
return new List<Neo4jConnectionString>();
|
||||
@@ -47,7 +47,7 @@ namespace Xamarin.Neo4j.Managers
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(connectionStrings);
|
||||
|
||||
await SecureStorage.SetAsync(ConnectionStringsKey, json);
|
||||
await SecureStorage.Default.SetAsync(ConnectionStringsKey, json);
|
||||
}
|
||||
|
||||
public static async Task DeleteConnectionString(Neo4jConnectionString neo4JConnectionString)
|
||||
@@ -63,12 +63,12 @@ namespace Xamarin.Neo4j.Managers
|
||||
public static async Task UpdateConnectionString(Guid id, Neo4jConnectionString connectionString)
|
||||
{
|
||||
var connectionStrings = await GetConnectionStrings();
|
||||
|
||||
|
||||
var connectionStringToUpdate = connectionStrings.FirstOrDefault(cs => cs.Id == id);
|
||||
|
||||
|
||||
if (connectionStringToUpdate == null)
|
||||
return;
|
||||
|
||||
|
||||
connectionStringToUpdate.Scheme = connectionString.Scheme;
|
||||
connectionStringToUpdate.Host = connectionString.Host;
|
||||
connectionStringToUpdate.Username = connectionString.Username;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// LabelColorManager.cs
|
||||
//
|
||||
// Trevi Awater
|
||||
// 30-03-2026
|
||||
//
|
||||
// © Xamarin.Neo4j
|
||||
//
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Maui.Storage;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Xamarin.Neo4j.Managers
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists label → color mappings per connection, so node labels keep
|
||||
/// consistent colors across queries (like the Neo4j Browser).
|
||||
/// </summary>
|
||||
public static class LabelColorManager
|
||||
{
|
||||
private const string PreferenceKeyPrefix = "label_colors_";
|
||||
|
||||
// Neo4j Browser–style palette
|
||||
private static readonly string[] Palette =
|
||||
{
|
||||
"#4C8EDA", // blue
|
||||
"#D9534F", // red
|
||||
"#57A773", // green
|
||||
"#F0AD4E", // amber
|
||||
"#9B59B6", // purple
|
||||
"#E06CA0", // pink
|
||||
"#00B5AD", // teal
|
||||
"#DA7B3A", // orange
|
||||
"#6477B0", // slate blue
|
||||
"#C0CA33", // lime
|
||||
"#7E57C2", // deep purple
|
||||
"#26A69A", // cyan-teal
|
||||
"#EF5350", // coral
|
||||
"#5C6BC0", // indigo
|
||||
"#66BB6A", // light green
|
||||
"#FFA726", // deep amber
|
||||
"#AB47BC", // magenta
|
||||
"#29B6F6", // light blue
|
||||
"#EC407A", // hot pink
|
||||
"#8D6E63", // brown
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> _cache;
|
||||
private static Guid? _cachedConnectionId;
|
||||
|
||||
public static string GetColor(Guid connectionId, string label)
|
||||
{
|
||||
var map = GetMap(connectionId);
|
||||
|
||||
if (map.TryGetValue(label, out var color))
|
||||
return color;
|
||||
|
||||
// Assign next unused palette color
|
||||
color = Palette[map.Count % Palette.Length];
|
||||
map[label] = color;
|
||||
SaveMap(connectionId, map);
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> GetMap(Guid connectionId)
|
||||
{
|
||||
if (_cachedConnectionId == connectionId && _cache != null)
|
||||
return _cache;
|
||||
|
||||
var key = PreferenceKeyPrefix + connectionId;
|
||||
var json = Preferences.Default.Get(key, (string)null);
|
||||
|
||||
_cache = string.IsNullOrEmpty(json)
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: JsonConvert.DeserializeObject<Dictionary<string, string>>(json)
|
||||
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_cachedConnectionId = connectionId;
|
||||
return _cache;
|
||||
}
|
||||
|
||||
private static void SaveMap(Guid connectionId, Dictionary<string, string> map)
|
||||
{
|
||||
var key = PreferenceKeyPrefix + connectionId;
|
||||
var json = JsonConvert.SerializeObject(map);
|
||||
Preferences.Default.Set(key, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,8 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.Storage;
|
||||
using Newtonsoft.Json;
|
||||
using Xamarin.Essentials;
|
||||
using Xamarin.Neo4j.Models;
|
||||
|
||||
namespace Xamarin.Neo4j.Managers
|
||||
@@ -22,7 +21,7 @@ namespace Xamarin.Neo4j.Managers
|
||||
|
||||
public static List<Query> GetSavedQueries()
|
||||
{
|
||||
var json = Preferences.Get(SavedQueriesKey, null);
|
||||
var json = Preferences.Default.Get(SavedQueriesKey, (string)null);
|
||||
|
||||
if (string.IsNullOrEmpty(json))
|
||||
return new List<Query>();
|
||||
@@ -43,7 +42,7 @@ namespace Xamarin.Neo4j.Managers
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(savedQueries);
|
||||
|
||||
Preferences.Set(SavedQueriesKey, json);
|
||||
Preferences.Default.Set(SavedQueriesKey, json);
|
||||
}
|
||||
|
||||
public static void DeleteSavedQuery(Query query)
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
using System;
|
||||
using System.Windows.Input;
|
||||
using Xamarin.Essentials;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.Controls;
|
||||
|
||||
namespace Xamarin.Neo4j.Models
|
||||
{
|
||||
@@ -25,6 +25,6 @@ namespace Xamarin.Neo4j.Models
|
||||
/// <summary>
|
||||
/// Opens the repository in the browser.
|
||||
/// </summary>
|
||||
public ICommand OpenRepo => new Command(() => Launcher.OpenAsync(new Uri(Repo)));
|
||||
public ICommand OpenRepo => new Command(() => Launcher.Default.OpenAsync(new Uri(Repo)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,18 +30,24 @@ namespace Xamarin.Neo4j.Models
|
||||
public Tuple<string, bool, bool> ParseHost()
|
||||
{
|
||||
var fullHost = Scheme + Host;
|
||||
|
||||
|
||||
if (fullHost.StartsWith("neo4j://"))
|
||||
return new Tuple<string, bool, bool>(fullHost, false, false);
|
||||
|
||||
if (fullHost.StartsWith("bolt://"))
|
||||
return new Tuple<string, bool, bool>(fullHost.Replace("bolt://", "neo4j://"), false, false);
|
||||
|
||||
if (fullHost.StartsWith("neo4j+s://"))
|
||||
return new Tuple<string, bool, bool>(fullHost.Replace("neo4j+s://", "neo4j://"), true, false);
|
||||
return new Tuple<string, bool, bool>(fullHost, true, true);
|
||||
|
||||
if (fullHost.StartsWith("neo4j+ssc://"))
|
||||
return new Tuple<string, bool, bool>(fullHost.Replace("neo4j+ssc://", "neo4j://"), true, true);
|
||||
return new Tuple<string, bool, bool>(fullHost, true, true);
|
||||
|
||||
if (fullHost.StartsWith("bolt://"))
|
||||
return new Tuple<string, bool, bool>(fullHost, false, false);
|
||||
|
||||
if (fullHost.StartsWith("bolt+s://"))
|
||||
return new Tuple<string, bool, bool>(fullHost, true, true);
|
||||
|
||||
if (fullHost.StartsWith("bolt+ssc://"))
|
||||
return new Tuple<string, bool, bool>(fullHost, true, true);
|
||||
|
||||
throw new NotSupportedException("Unknown protocol.");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
Title="Add Connection"
|
||||
x:Class="Xamarin.Neo4j.Pages.AddConnectionPage">
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Xamarin.Neo4j.Models;
|
||||
using Xamarin.Neo4j.ViewModels;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
|
||||
@@ -40,17 +40,36 @@
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<ContentPage.Content>
|
||||
<ListView ItemsSource="{Binding ConnectionStrings}" HasUnevenRows="True" SelectionMode="None" ItemTapped="SetActive">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<controls:ConnectionCell IsActive="{Binding ., Converter={StaticResource isActiveConnection}}">
|
||||
<controls:ConnectionCell.ContextActions>
|
||||
<MenuItem Text="Edit" Command="{Binding BindingContext.Commands[EditConnectionString], Source={x:Reference connectionsPage}}" CommandParameter="{Binding .}" />
|
||||
<MenuItem Text="Delete" Command="{Binding BindingContext.Commands[DeleteConnectionString], Source={x:Reference connectionsPage}}" CommandParameter="{Binding .}" IsDestructive="true" />
|
||||
</controls:ConnectionCell.ContextActions>
|
||||
</controls:ConnectionCell>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
<Grid>
|
||||
<ListView ItemsSource="{Binding ConnectionStrings}" HasUnevenRows="True" SelectionMode="None" ItemTapped="SetActive" IsVisible="{Binding HasItems}">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<controls:ConnectionCell IsActive="{Binding ., Converter={StaticResource isActiveConnection}}">
|
||||
<controls:ConnectionCell.ContextActions>
|
||||
<MenuItem Text="Edit" Command="{Binding BindingContext.Commands[EditConnectionString], Source={x:Reference connectionsPage}}" CommandParameter="{Binding .}" />
|
||||
<MenuItem Text="Delete" Command="{Binding BindingContext.Commands[DeleteConnectionString], Source={x:Reference connectionsPage}}" CommandParameter="{Binding .}" IsDestructive="true" />
|
||||
</controls:ConnectionCell.ContextActions>
|
||||
</controls:ConnectionCell>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
|
||||
<StackLayout IsVisible="{Binding IsEmpty}" VerticalOptions="Center" HorizontalOptions="Center" Spacing="12">
|
||||
<Label Text="{x:Static fonts:FontAwesomeSolid.Server}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="48"
|
||||
TextColor="{StaticResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="No connections"
|
||||
FontSize="18"
|
||||
FontAttributes="Bold"
|
||||
TextColor="{StaticResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Tap + to add a connection"
|
||||
FontSize="14"
|
||||
TextColor="{StaticResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
</StackLayout>
|
||||
</Grid>
|
||||
</ContentPage.Content>
|
||||
</ContentPage>
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Xamarin.Neo4j.Managers;
|
||||
using Xamarin.Neo4j.Models;
|
||||
using Xamarin.Neo4j.ViewModels;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
Title="Graph"
|
||||
x:Class="Xamarin.Neo4j.Pages.GraphPage">
|
||||
<ContentPage.Content>
|
||||
<WebView Source="{Binding Source}" HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand" />
|
||||
<WebView x:Name="webView" Source="{Binding Source}" HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand" />
|
||||
</ContentPage.Content>
|
||||
</ContentPage>
|
||||
|
||||
@@ -3,10 +3,16 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Xamarin.Neo4j.Managers;
|
||||
using Xamarin.Neo4j.Models;
|
||||
using Xamarin.Neo4j.Services;
|
||||
using Xamarin.Neo4j.Utilities;
|
||||
using Xamarin.Neo4j.ViewModels;
|
||||
|
||||
namespace Xamarin.Neo4j.Pages
|
||||
@@ -14,11 +20,58 @@ namespace Xamarin.Neo4j.Pages
|
||||
[XamlCompilation(XamlCompilationOptions.Compile)]
|
||||
public partial class GraphPage : ContentPage
|
||||
{
|
||||
public GraphPage(string html)
|
||||
public GraphPage(string html, Neo4jConnectionString connectionString = null, Neo4jService neo4jService = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
BindingContext = new GraphViewModel(Navigation, html);
|
||||
|
||||
webView.Navigating += OnWebViewNavigating;
|
||||
}
|
||||
|
||||
private async void OnWebViewNavigating(object sender, WebNavigatingEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[Graph] Navigating: {e.Url}");
|
||||
|
||||
if (!e.Url.Contains("expand") || !e.Url.Contains("nodeId")) return;
|
||||
|
||||
e.Cancel = true;
|
||||
|
||||
try
|
||||
{
|
||||
var connectionString = ConnectionStringManager.ActiveConnectionString;
|
||||
if (connectionString == null)
|
||||
{
|
||||
Console.WriteLine("[Graph] Expand skipped: no active connection");
|
||||
return;
|
||||
}
|
||||
|
||||
var neo4jService = IPlatformApplication.Current.Services.GetRequiredService<Neo4jService>();
|
||||
|
||||
var match = System.Text.RegularExpressions.Regex.Match(e.Url, @"nodeId=(\d+)");
|
||||
if (!match.Success || !long.TryParse(match.Groups[1].Value, out var nodeId)) return;
|
||||
|
||||
Console.WriteLine($"[Graph] Expanding node {nodeId}");
|
||||
|
||||
var result = await neo4jService.ExpandNode(nodeId, connectionString);
|
||||
|
||||
if (!result.Success || result.Results == null)
|
||||
{
|
||||
Console.WriteLine($"[Graph] Expand query failed: {result.ErrorMessage}");
|
||||
return;
|
||||
}
|
||||
|
||||
var (nodesJson, edgesJson) = GraphDataHelper.BuildJson(result.Results, connectionString.Id);
|
||||
|
||||
Console.WriteLine($"[Graph] Pushing {nodesJson.Length} chars nodes, {edgesJson.Length} chars edges");
|
||||
|
||||
var js = $"window.addGraphData({nodesJson}, {edgesJson}, {nodeId})";
|
||||
await webView.EvaluateJavaScriptAsync(js);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Graph] Expand failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls"
|
||||
Title="Software Licenses"
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Xamarin.Neo4j.ViewModels;
|
||||
|
||||
namespace Xamarin.Neo4j.Pages
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
Title="Queries"
|
||||
@@ -14,16 +14,35 @@
|
||||
</ContentPage.IconImageSource>
|
||||
|
||||
<ContentPage.Content>
|
||||
<ListView ItemsSource="{Binding Queries}" ItemTapped="StartSessionWithQuery">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextCell Text="{Binding Name}" Detail="{Binding QueryText}">
|
||||
<TextCell.ContextActions>
|
||||
<MenuItem Text="Delete" Command="{Binding BindingContext.Commands[DeleteQuery], Source={x:Reference queriesPage} }" CommandParameter="{Binding .}" IsDestructive="true" />
|
||||
</TextCell.ContextActions>
|
||||
</TextCell>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
<Grid>
|
||||
<ListView ItemsSource="{Binding Queries}" ItemTapped="StartSessionWithQuery" IsVisible="{Binding HasItems}">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextCell Text="{Binding Name}" Detail="{Binding QueryText}">
|
||||
<TextCell.ContextActions>
|
||||
<MenuItem Text="Delete" Command="{Binding BindingContext.Commands[DeleteQuery], Source={x:Reference queriesPage} }" CommandParameter="{Binding .}" IsDestructive="true" />
|
||||
</TextCell.ContextActions>
|
||||
</TextCell>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
|
||||
<StackLayout IsVisible="{Binding IsEmpty}" VerticalOptions="Center" HorizontalOptions="Center" Spacing="12">
|
||||
<Label Text="{x:Static fonts:FontAwesomeSolid.Code}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="48"
|
||||
TextColor="{StaticResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="No saved queries"
|
||||
FontSize="18"
|
||||
FontAttributes="Bold"
|
||||
TextColor="{StaticResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Save a query from a session to see it here"
|
||||
FontSize="14"
|
||||
TextColor="{StaticResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
</StackLayout>
|
||||
</Grid>
|
||||
</ContentPage.Content>
|
||||
</ContentPage>
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Xamarin.Neo4j.Models;
|
||||
using Xamarin.Neo4j.ViewModels;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<TabbedPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<TabbedPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:pages="clr-namespace:Xamarin.Neo4j.Pages;assembly=Xamarin.Neo4j"
|
||||
x:Class="Xamarin.Neo4j.Pages.RootPage">
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
|
||||
namespace Xamarin.Neo4j.Pages
|
||||
{
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
|
||||
xmlns:pancakeView="clr-namespace:Xamarin.Forms.PancakeView;assembly=Xamarin.Forms.PancakeView"
|
||||
x:Class="Xamarin.Neo4j.Pages.SessionPage">
|
||||
<NavigationPage.TitleView>
|
||||
<StackLayout Spacing="0">
|
||||
@@ -37,23 +36,39 @@
|
||||
|
||||
<ContentPage.Content>
|
||||
<StackLayout VerticalOptions="FillAndExpand">
|
||||
<pancakeView:PancakeView BackgroundColor="White" CornerRadius="2" Padding="4" Margin="16">
|
||||
<pancakeView:PancakeView CornerRadius="2">
|
||||
<pancakeView:PancakeView.Border>
|
||||
<pancakeView:Border Thickness="2" Color="#d8e5f1" />
|
||||
</pancakeView:PancakeView.Border>
|
||||
<Border BackgroundColor="White" StrokeShape="RoundRectangle 2" Padding="4" Margin="16">
|
||||
<Border Stroke="#d8e5f1" StrokeThickness="2" StrokeShape="RoundRectangle 2">
|
||||
<controls:QueryEditor Text="{Binding Query}" ExecuteClicked="ExecuteQuery" FontSize="14" MaxHeight="200" HorizontalOptions="FillAndExpand" AutoSize="TextChanges" />
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<controls:QueryEditor Text="{Binding Query}" ExecuteClicked="ExecuteQuery" FontSize="14" MaxHeight="200" HorizontalOptions="FillAndExpand" AutoSize="TextChanges" />
|
||||
</pancakeView:PancakeView>
|
||||
</pancakeView:PancakeView>
|
||||
<Grid VerticalOptions="FillAndExpand">
|
||||
<CollectionView x:Name="resultsCollection" ItemsSource="{Binding QueryResults}" VerticalOptions="FillAndExpand">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<controls:QueryResultView CloseRequested="CloseResultView" />
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
|
||||
<CollectionView x:Name="resultsCollection" ItemsSource="{Binding QueryResults}" VerticalOptions="FillAndExpand">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<controls:QueryResultView CloseRequested="CloseResultView" />
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
<StackLayout IsVisible="{Binding IsEmpty}" VerticalOptions="Center" HorizontalOptions="Center" Spacing="12" Padding="32">
|
||||
<Label Text="{x:Static fonts:FontAwesomeSolid.Database}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="48"
|
||||
TextColor="{StaticResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="No results yet"
|
||||
FontSize="18"
|
||||
FontAttributes="Bold"
|
||||
TextColor="{StaticResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Write a query and tap the play button to run it"
|
||||
FontSize="14"
|
||||
TextColor="{StaticResource SecondaryTextColor}"
|
||||
HorizontalTextAlignment="Center"
|
||||
HorizontalOptions="Center" />
|
||||
</StackLayout>
|
||||
</Grid>
|
||||
</StackLayout>
|
||||
</ContentPage.Content>
|
||||
</ContentPage>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Xamarin.Neo4j.Models;
|
||||
using Xamarin.Neo4j.Utilities;
|
||||
using Xamarin.Neo4j.ViewModels;
|
||||
@@ -10,8 +10,6 @@ namespace Xamarin.Neo4j.Pages
|
||||
[XamlCompilation(XamlCompilationOptions.Compile)]
|
||||
public partial class SessionPage : ContentPage
|
||||
{
|
||||
private bool _canCompleteEntry = true;
|
||||
|
||||
private SessionViewModel ViewModel => (SessionViewModel) BindingContext;
|
||||
|
||||
public SessionPage(Neo4jConnectionString connectionString, string initialQuery = null)
|
||||
@@ -20,10 +18,10 @@ namespace Xamarin.Neo4j.Pages
|
||||
|
||||
BindingContext = new SessionViewModel(Navigation, connectionString, initialQuery);
|
||||
|
||||
MessagingCenter.Subscribe<SessionViewModel>(this, "ResetScroll", (sender) =>
|
||||
ViewModel.ScrollToTop += (_, _) =>
|
||||
{
|
||||
resultsCollection.ScrollTo(0, 0, ScrollToPosition.Start, true);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private void FocusDatabasePicker(object sender, EventArgs e)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
Title="Settings"
|
||||
@@ -13,17 +13,27 @@
|
||||
</ContentPage.IconImageSource>
|
||||
|
||||
<ContentPage.Content>
|
||||
<StackLayout VerticalOptions="FillAndExpand">
|
||||
<TableView VerticalOptions="StartAndExpand" Intent="Settings" Background="Transparent">
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<TableView Grid.Row="0" Intent="Settings" Background="Transparent" HasUnevenRows="True">
|
||||
<TableRoot>
|
||||
<TableSection>
|
||||
<TableSection Title="Support">
|
||||
<TextCell Text="Contact Support" Command="{Binding Commands[ContactSupport]}" />
|
||||
<TextCell Text="Rate PocketGraph" Command="{Binding Commands[RateApp]}" />
|
||||
</TableSection>
|
||||
|
||||
<TableSection Title="Data">
|
||||
<TextCell Text="Clear Saved Connections" Command="{Binding Commands[ClearConnections]}" />
|
||||
<TextCell Text="Clear Saved Queries" Command="{Binding Commands[ClearQueries]}" />
|
||||
</TableSection>
|
||||
|
||||
<TableSection Title="About">
|
||||
<TextCell Text="{Binding VersionLabel}" />
|
||||
<TextCell Text="Software Licenses" Command="{Binding Commands[OpenLicensesPage]}" />
|
||||
</TableSection>
|
||||
</TableRoot>
|
||||
</TableView>
|
||||
|
||||
<StackLayout Spacing="15" Margin="25" VerticalOptions="EndAndExpand">
|
||||
<StackLayout Grid.Row="1" Spacing="15" Padding="25">
|
||||
<Label Text="A product of" HorizontalOptions="Center" FontAttributes="Italic" FontSize="12" TextColor="{StaticResource SecondaryTextColor}" />
|
||||
<Image Source="resoftware.png" HorizontalOptions="Center" WidthRequest="100">
|
||||
<Image.GestureRecognizers>
|
||||
@@ -31,6 +41,6 @@
|
||||
</Image.GestureRecognizers>
|
||||
</Image>
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</Grid>
|
||||
</ContentPage.Content>
|
||||
</ContentPage>
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Xamarin.Neo4j.ViewModels;
|
||||
|
||||
namespace Xamarin.Neo4j.Pages
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:converters="clr-namespace:Xamarin.Neo4j.Converters;assembly=Xamarin.Neo4j"
|
||||
Title="{Binding Title}"
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Xamarin.Neo4j.Models;
|
||||
using Xamarin.Neo4j.ViewModels;
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Neo4j.Driver;
|
||||
|
||||
namespace Xamarin.Neo4j.Security
|
||||
{
|
||||
public class InsecureTrustManager : TrustManager
|
||||
{
|
||||
public override bool ValidateServerCertificate(Uri uri, X509Certificate2 certificate, X509Chain chain,
|
||||
SslPolicyErrors sslPolicyErrors)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Devices;
|
||||
using Neo4j.Driver;
|
||||
using Neo4jClient;
|
||||
using Neo4jClient.Cypher;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Neo4j.Models;
|
||||
using Xamarin.Neo4j.Security;
|
||||
using Xamarin.Neo4j.Services;
|
||||
using Xamarin.Neo4j.Services.Interfaces;
|
||||
using Xamarin.Neo4j.Utilities;
|
||||
|
||||
[assembly: Dependency(typeof(Neo4jService))]
|
||||
|
||||
namespace Xamarin.Neo4j.Services
|
||||
{
|
||||
public class Neo4jService
|
||||
@@ -29,7 +31,7 @@ namespace Xamarin.Neo4j.Services
|
||||
|
||||
public Neo4jService()
|
||||
{
|
||||
var trustManagerService = DependencyService.Get<ITrustManagerService>();
|
||||
var trustManagerService = IPlatformApplication.Current.Services.GetRequiredService<ITrustManagerService>();
|
||||
|
||||
_nativeTrustManager = trustManagerService.GetNativeTrustManager();
|
||||
}
|
||||
@@ -45,15 +47,22 @@ namespace Xamarin.Neo4j.Services
|
||||
var driver = GraphDatabase.Driver(url,
|
||||
AuthTokens.Basic(connectionString.Username, connectionString.Password), (config) =>
|
||||
{
|
||||
config.WithEncryptionLevel(isEncrypted ? EncryptionLevel.Encrypted : EncryptionLevel.None);
|
||||
|
||||
if (!ignoreTrust && Device.RuntimePlatform == Device.iOS)
|
||||
if (!ignoreTrust && DeviceInfo.Platform == DevicePlatform.iOS)
|
||||
config.WithTrustManager(_nativeTrustManager);
|
||||
});
|
||||
|
||||
GraphClient = new BoltGraphClient(driver);
|
||||
|
||||
await GraphClient.ConnectAsync();
|
||||
try
|
||||
{
|
||||
await GraphClient.ConnectAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ConnectAsync runs Neo4j-specific metadata queries that non-Neo4j
|
||||
// servers (e.g. Memgraph) don't support. The underlying driver is
|
||||
// still connected and usable for running queries.
|
||||
}
|
||||
}
|
||||
|
||||
catch (ServiceUnavailableException e)
|
||||
@@ -81,31 +90,48 @@ namespace Xamarin.Neo4j.Services
|
||||
return new Tuple<bool, string>(false, e.Message);
|
||||
}
|
||||
|
||||
catch (Exception e)
|
||||
{
|
||||
return new Tuple<bool, string>(false, $"[{e.GetType().FullName}] {e.Message}\n\n{e.InnerException?.Message}\n\n{e.StackTrace}");
|
||||
}
|
||||
|
||||
return new Tuple<bool, string>(true, "Connection was successful!");
|
||||
}
|
||||
|
||||
public async Task<List<Database>> LoadDatabases()
|
||||
{
|
||||
var session = GraphClient.Driver.AsyncSession(d => d.WithDatabase("system"));
|
||||
var cursor = await session.RunAsync("SHOW DATABASES;");
|
||||
|
||||
var databases = new List<Database>();
|
||||
|
||||
while (await cursor.FetchAsync())
|
||||
try
|
||||
{
|
||||
var name = cursor.Current.Values["name"].As<string>();
|
||||
var status = cursor.Current.Values["currentStatus"].As<string>();
|
||||
var @default = cursor.Current.Values["default"].As<bool>();
|
||||
var session = GraphClient.Driver.AsyncSession(d => d.WithDatabase("system"));
|
||||
var cursor = await session.RunAsync("SHOW DATABASES;");
|
||||
|
||||
databases.Add(new Database()
|
||||
var databases = new List<Database>();
|
||||
|
||||
while (await cursor.FetchAsync())
|
||||
{
|
||||
Name = name,
|
||||
Status = status,
|
||||
Default = @default
|
||||
});
|
||||
}
|
||||
var name = cursor.Current.Values["name"].As<string>();
|
||||
var status = cursor.Current.Values["currentStatus"].As<string>();
|
||||
var @default = cursor.Current.Values["default"].As<bool>();
|
||||
|
||||
return databases;
|
||||
databases.Add(new Database()
|
||||
{
|
||||
Name = name,
|
||||
Status = status,
|
||||
Default = @default
|
||||
});
|
||||
}
|
||||
|
||||
return databases;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Memgraph and some Neo4j setups don't expose a system database.
|
||||
// Fall back to a single default database.
|
||||
return new List<Database>
|
||||
{
|
||||
new Database { Name = "memgraph", Status = "online", Default = true }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<QueryResult> ExecuteQuery(string query, Neo4jConnectionString connectionString)
|
||||
@@ -171,5 +197,66 @@ namespace Xamarin.Neo4j.Services
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<QueryResult> ExpandNode(long nodeId, Neo4jConnectionString connectionString)
|
||||
{
|
||||
var query = "MATCH (n)-[r]-(m) WHERE id(n) = $nodeId RETURN n, r, m";
|
||||
|
||||
var results = new Dictionary<string, List<object>>();
|
||||
|
||||
try
|
||||
{
|
||||
var session = GraphClient.Driver.AsyncSession(d => d.WithDatabase(connectionString.Database));
|
||||
var cursor = await session.RunAsync(query, new { nodeId });
|
||||
|
||||
while (await cursor.FetchAsync())
|
||||
{
|
||||
foreach (var key in cursor.Current.Keys)
|
||||
if (results.ContainsKey(key) == false)
|
||||
results.Add(key, new List<object>());
|
||||
|
||||
foreach (var record in cursor.Current.Values)
|
||||
{
|
||||
var value = record.Value;
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case INode _:
|
||||
results[record.Key].Add(value.As<INode>());
|
||||
break;
|
||||
|
||||
case IRelationship _:
|
||||
results[record.Key].Add(value.As<IRelationship>());
|
||||
break;
|
||||
|
||||
default:
|
||||
results[record.Key].Add(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new QueryResult()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Success = true,
|
||||
CanDisplayGraph = true,
|
||||
Query = query,
|
||||
ConnectionString = connectionString,
|
||||
Results = results
|
||||
};
|
||||
}
|
||||
catch (ClientException e)
|
||||
{
|
||||
return new QueryResult()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Success = false,
|
||||
Query = query,
|
||||
ConnectionString = connectionString,
|
||||
ErrorMessage = e.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ResourceDictionary xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Xamarin.Neo4j.Themes.DarkTheme">
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// © Xamarin.Neo4j
|
||||
//
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
|
||||
namespace Xamarin.Neo4j.Themes
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ResourceDictionary xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Xamarin.Neo4j.Themes.Fonts">
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
|
||||
namespace Xamarin.Neo4j.Themes
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ResourceDictionary xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Xamarin.Neo4j.Themes.LightTheme">
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Xaml;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
|
||||
namespace Xamarin.Neo4j.Themes
|
||||
{
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// GraphDataHelper.cs
|
||||
//
|
||||
// Trevi Awater
|
||||
// 30-03-2026
|
||||
//
|
||||
// © Xamarin.Neo4j
|
||||
//
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Neo4j.Driver;
|
||||
using Xamarin.Neo4j.Managers;
|
||||
|
||||
namespace Xamarin.Neo4j.Utilities
|
||||
{
|
||||
public static class GraphDataHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts INode and IRelationship objects from a query result dictionary and
|
||||
/// builds JSON arrays suitable for the visgraph.html canvas renderer.
|
||||
/// </summary>
|
||||
public static (string NodesJson, string EdgesJson) BuildJson(
|
||||
Dictionary<string, List<object>> results,
|
||||
Guid connectionId)
|
||||
{
|
||||
var nodeDict = new Dictionary<long, INode>();
|
||||
var relationships = new List<IRelationship>();
|
||||
|
||||
foreach (var values in results.Values)
|
||||
{
|
||||
foreach (var obj in values)
|
||||
{
|
||||
switch (obj)
|
||||
{
|
||||
case INode node:
|
||||
nodeDict[node.Id] = node;
|
||||
break;
|
||||
case IRelationship rel:
|
||||
relationships.Add(rel);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nodesJson = "[" + string.Join(",", nodeDict.Values.Select(n =>
|
||||
{
|
||||
var label = n.Labels.FirstOrDefault() ?? "Node";
|
||||
var title = string.Join(", ", n.Properties.Select(p => $"{p.Key}: {p.Value}"));
|
||||
var color = LabelColorManager.GetColor(connectionId, label);
|
||||
return $"{{\"id\":{n.Id},\"label\":\"{EscapeJs(label)}\",\"title\":\"{EscapeJs(title)}\",\"color\":\"{color}\"}}";
|
||||
})) + "]";
|
||||
|
||||
var edgesJson = "[" + string.Join(",", relationships.Select(r =>
|
||||
$"{{\"from\":{r.StartNodeId},\"to\":{r.EndNodeId},\"label\":\"{EscapeJs(r.Type)}\"}}")) + "]";
|
||||
|
||||
return (nodesJson, edgesJson);
|
||||
}
|
||||
|
||||
public static string EscapeJs(string s)
|
||||
{
|
||||
return s?.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "") ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,11 @@
|
||||
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using Acr.UserDialogs;
|
||||
using Xamarin.Essentials;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Xamarin.Neo4j.Annotations;
|
||||
using Xamarin.Neo4j.Managers;
|
||||
using Xamarin.Neo4j.Models;
|
||||
@@ -26,7 +25,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
public class AddConnectionViewModel : ViewModelBase, INotifyPropertyChanged
|
||||
{
|
||||
private Guid? _id;
|
||||
|
||||
|
||||
private string _scheme, _host, _username, _password;
|
||||
|
||||
private readonly Neo4jService _neo4jService;
|
||||
@@ -37,11 +36,11 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
if (neo4JConnectionString == null)
|
||||
InitializeDefaultValues();
|
||||
|
||||
else
|
||||
|
||||
else
|
||||
InitializeValues(neo4JConnectionString);
|
||||
|
||||
_neo4jService = DependencyService.Resolve<Neo4jService>();
|
||||
_neo4jService = IPlatformApplication.Current.Services.GetRequiredService<Neo4jService>();
|
||||
|
||||
Commands.Add("Test", new Command(async () =>
|
||||
{
|
||||
@@ -49,7 +48,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
var (_, message) = await _neo4jService.EstablishConnection(connectionString);
|
||||
|
||||
await UserDialogs.Instance.AlertAsync(message);
|
||||
await Application.Current.MainPage.DisplayAlert("", message, "OK");
|
||||
}));
|
||||
|
||||
Commands.Add("Save", new Command(async () =>
|
||||
@@ -58,18 +57,18 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
if (_id.HasValue)
|
||||
await ConnectionStringManager.UpdateConnectionString(_id.Value, connectionString);
|
||||
|
||||
|
||||
else
|
||||
{
|
||||
var namePromptResult = await UserDialogs.Instance.PromptAsync("How do you want to name this connection?", "Save Connection", "Save", "Cancel");
|
||||
var name = await Application.Current.MainPage.DisplayPromptAsync("Save Connection", "How do you want to name this connection?", "Save", "Cancel");
|
||||
|
||||
if (!namePromptResult.Ok || string.IsNullOrWhiteSpace(namePromptResult.Value))
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return;
|
||||
|
||||
connectionString.Name = namePromptResult.Value;
|
||||
|
||||
connectionString.Name = name;
|
||||
|
||||
await ConnectionStringManager.AddConnectionString(connectionString);
|
||||
}
|
||||
}
|
||||
|
||||
await Navigation.PopAsync();
|
||||
}));
|
||||
@@ -84,14 +83,14 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
await Navigation.PushAsync(new SessionPage(connectionString));
|
||||
|
||||
else
|
||||
await UserDialogs.Instance.AlertAsync(message);
|
||||
await Application.Current.MainPage.DisplayAlert("", message, "OK");
|
||||
}));
|
||||
}
|
||||
|
||||
private void InitializeValues(Neo4jConnectionString neo4JConnectionString)
|
||||
{
|
||||
_id = neo4JConnectionString.Id;
|
||||
|
||||
|
||||
Scheme = neo4JConnectionString.Scheme;
|
||||
Host = neo4JConnectionString.Host;
|
||||
Username = neo4JConnectionString.Username;
|
||||
@@ -135,7 +134,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
OnPropertyChanged(nameof(Scheme));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public string Host
|
||||
{
|
||||
get => _host;
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Acr.UserDialogs;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Xamarin.Neo4j.Annotations;
|
||||
using Xamarin.Neo4j.Managers;
|
||||
using Xamarin.Neo4j.Models;
|
||||
@@ -39,7 +39,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
await Navigation.PushAsync(new AddConnectionPage(neo4jConnectionString));
|
||||
}));
|
||||
|
||||
|
||||
Commands.Add("DeleteConnectionString", new Command(async (o) =>
|
||||
{
|
||||
if (!(o is Neo4jConnectionString neo4jConnectionString))
|
||||
@@ -54,7 +54,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
if (ConnectionStringManager.ActiveConnectionString == null)
|
||||
{
|
||||
await UserDialogs.Instance.AlertAsync("Please select a connection before starting a session.");
|
||||
await Application.Current.MainPage.DisplayAlert("", "Please select a connection before starting a session.", "OK");
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -78,7 +78,6 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
ConnectionStringManager.ActiveConnectionString = ConnectionStringManager.ActiveConnectionString?.Id == connectionString.Id ? null : connectionString;
|
||||
|
||||
// HACK: This causes the Converter to re-execute. This can be done much cleaner.
|
||||
LoadConnectionStrings();
|
||||
}
|
||||
|
||||
@@ -93,9 +92,15 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
_connectionStrings = value;
|
||||
|
||||
OnPropertyChanged(nameof(ConnectionStrings));
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
OnPropertyChanged(nameof(HasItems));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEmpty => !(_connectionStrings?.Any() ?? false);
|
||||
|
||||
public bool HasItems => !IsEmpty;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
//
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Xamarin.Neo4j.Annotations;
|
||||
using Xamarin.Neo4j.Models;
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Newtonsoft.Json;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Neo4j.Annotations;
|
||||
using License = Xamarin.Neo4j.Models.License;
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Acr.UserDialogs;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Xamarin.Neo4j.Annotations;
|
||||
using Xamarin.Neo4j.Managers;
|
||||
using Xamarin.Neo4j.Models;
|
||||
@@ -42,7 +42,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
if (ConnectionStringManager.ActiveConnectionString == null)
|
||||
{
|
||||
await UserDialogs.Instance.AlertAsync("Please select a connection before starting a session.");
|
||||
await Application.Current.MainPage.DisplayAlert("", "Please select a connection before starting a session.", "OK");
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -72,9 +72,15 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
_queries = value;
|
||||
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
OnPropertyChanged(nameof(HasItems));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEmpty => !(_queries?.Any() ?? false);
|
||||
|
||||
public bool HasItems => !IsEmpty;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Acr.UserDialogs;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Internals;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Xamarin.Neo4j.Annotations;
|
||||
using Xamarin.Neo4j.Models;
|
||||
using Xamarin.Neo4j.Pages;
|
||||
@@ -31,6 +29,8 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
public event EventHandler ScrollToTop;
|
||||
|
||||
private readonly Neo4jService _neo4jService;
|
||||
|
||||
private readonly IScreenSizeService _screenSizeService;
|
||||
@@ -49,11 +49,12 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
|
||||
_neo4jService = DependencyService.Resolve<Neo4jService>();
|
||||
_screenSizeService = DependencyService.Resolve<IScreenSizeService>();
|
||||
_neo4jService = IPlatformApplication.Current.Services.GetRequiredService<Neo4jService>();
|
||||
_screenSizeService = IPlatformApplication.Current.Services.GetRequiredService<IScreenSizeService>();
|
||||
|
||||
Query = initialQuery;
|
||||
QueryResults = new ObservableCollection<QueryResult>();
|
||||
QueryResults.CollectionChanged += (_, _) => OnPropertyChanged(nameof(IsEmpty));
|
||||
|
||||
Commands.Add("ExecuteQuery", new Command(async () =>
|
||||
{
|
||||
@@ -68,11 +69,11 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
QueryResults.Insert(0, result);
|
||||
|
||||
MessagingCenter.Send(this, "ResetScroll");
|
||||
ScrollToTop?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
else
|
||||
await UserDialogs.Instance.AlertAsync(result.ErrorMessage);
|
||||
await Application.Current.MainPage.DisplayAlert("", result.ErrorMessage, "OK");
|
||||
}));
|
||||
|
||||
InitializeConnection(connectionString);
|
||||
@@ -84,25 +85,25 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
if (!isConnected)
|
||||
{
|
||||
await UserDialogs.Instance.AlertAsync(message);
|
||||
|
||||
await Application.Current.MainPage.DisplayAlert("", message, "OK");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
AvailableDatabases = await _neo4jService.LoadDatabases();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(connectionString.Database))
|
||||
CurrentDatabase = AvailableDatabases.SingleOrDefault(ad => ad.Name == connectionString.Database);
|
||||
|
||||
if (CurrentDatabase == null)
|
||||
CurrentDatabase = AvailableDatabases.Single(ad => ad.Default);
|
||||
CurrentDatabase = AvailableDatabases.SingleOrDefault(ad => ad.Default) ?? AvailableDatabases.FirstOrDefault();
|
||||
}
|
||||
|
||||
public void DeleteQueryResult(QueryResult queryResult)
|
||||
{
|
||||
var index = QueryResults.IndexOf(qr => qr.Id == queryResult.Id);
|
||||
|
||||
QueryResults.RemoveAt(index);
|
||||
var item = QueryResults.FirstOrDefault(qr => qr.Id == queryResult.Id);
|
||||
if (item != null)
|
||||
QueryResults.Remove(item);
|
||||
}
|
||||
|
||||
[NotifyPropertyChangedInvocator]
|
||||
@@ -122,6 +123,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
_currentDatabase = value;
|
||||
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(CanExecuteQuery));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,11 +160,14 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
_query = value;
|
||||
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(CanExecuteQuery));
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanExecuteQuery => !string.IsNullOrWhiteSpace(Query) && CurrentDatabase != null;
|
||||
|
||||
public bool IsEmpty => QueryResults?.Count == 0;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,13 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xamarin.Essentials;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.ApplicationModel.Communication;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Devices;
|
||||
using Microsoft.Maui.Storage;
|
||||
using Xamarin.Neo4j.Annotations;
|
||||
using Xamarin.Neo4j.Pages;
|
||||
using Xamarin.Neo4j.Services.Interfaces;
|
||||
@@ -21,26 +26,89 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
public class SettingsViewModel : ViewModelBase, INotifyPropertyChanged
|
||||
{
|
||||
private string _versionLabel { get; set; }
|
||||
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
|
||||
private IVersionService _versionService { get; set; }
|
||||
|
||||
public SettingsViewModel(INavigation navigation) : base(navigation)
|
||||
{
|
||||
_versionService = IPlatformApplication.Current.Services.GetRequiredService<IVersionService>();
|
||||
|
||||
VersionLabel = $"Version: {_versionService.GetVersion()} (Build: {_versionService.GetBuild()})";
|
||||
|
||||
Commands.Add("OpenReSoftwareSite", new Command(async () =>
|
||||
{
|
||||
await Launcher.OpenAsync(new Uri("https://resoftware.nl/"));
|
||||
await Launcher.Default.OpenAsync(new Uri("https://resoftware.nl/"));
|
||||
}));
|
||||
|
||||
Commands.Add("OpenLicensesPage", new Command(async () =>
|
||||
{
|
||||
await Navigation.PushAsync(new LicensesPage());
|
||||
}));
|
||||
|
||||
_versionService = DependencyService.Get<IVersionService>();
|
||||
|
||||
VersionLabel = $"Version: {_versionService.GetVersion()} (Build: {_versionService.GetBuild()})";
|
||||
|
||||
Commands.Add("ContactSupport", new Command(async () =>
|
||||
{
|
||||
var version = _versionService.GetVersion();
|
||||
var build = _versionService.GetBuild();
|
||||
var deviceModel = DeviceInfo.Model;
|
||||
var manufacturer = DeviceInfo.Manufacturer;
|
||||
var osVersion = DeviceInfo.VersionString;
|
||||
var platform = DeviceInfo.Platform.ToString();
|
||||
var deviceType = DeviceInfo.DeviceType.ToString();
|
||||
var appName = AppInfo.Name;
|
||||
|
||||
var body = $"""
|
||||
|
||||
|
||||
--- Device Information ---
|
||||
App: {appName} v{version} (Build {build})
|
||||
Device: {manufacturer} {deviceModel}
|
||||
Platform: {platform} {osVersion}
|
||||
Device Type: {deviceType}
|
||||
""";
|
||||
|
||||
var message = new EmailMessage
|
||||
{
|
||||
To = ["support@resoftware.nl"],
|
||||
Subject = $"{appName} - Support Request",
|
||||
Body = body
|
||||
};
|
||||
|
||||
await Email.Default.ComposeAsync(message);
|
||||
}));
|
||||
|
||||
Commands.Add("RateApp", new Command(async () =>
|
||||
{
|
||||
await Launcher.Default.OpenAsync(
|
||||
new Uri("https://apps.apple.com/app/id1604368926?action=write-review"));
|
||||
}));
|
||||
|
||||
Commands.Add("ClearConnections", new Command(async () =>
|
||||
{
|
||||
var clear = await Application.Current.MainPage.DisplayAlert(
|
||||
"Clear Connections",
|
||||
"Are you sure you want to remove all saved connections?",
|
||||
"Clear", "Cancel");
|
||||
|
||||
if (clear)
|
||||
{
|
||||
SecureStorage.Default.Remove("connection_strings");
|
||||
}
|
||||
}));
|
||||
|
||||
Commands.Add("ClearQueries", new Command(async () =>
|
||||
{
|
||||
var clear = await Application.Current.MainPage.DisplayAlert(
|
||||
"Clear Saved Queries",
|
||||
"Are you sure you want to remove all saved queries?",
|
||||
"Clear", "Cancel");
|
||||
|
||||
if (clear)
|
||||
{
|
||||
Preferences.Default.Remove("saved_queries");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
[NotifyPropertyChangedInvocator]
|
||||
@@ -48,9 +116,9 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
|
||||
#region Bindable Properties
|
||||
|
||||
|
||||
public string VersionLabel
|
||||
{
|
||||
get => _versionLabel;
|
||||
@@ -62,8 +130,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
OnPropertyChanged(nameof(VersionLabel));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Xamarin.Neo4j.Annotations;
|
||||
using Xamarin.Neo4j.Models;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Windows.Input;
|
||||
using Xamarin.Forms;
|
||||
using Microsoft.Maui.Controls;
|
||||
|
||||
namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Graph</title>
|
||||
|
||||
<style type="text/css">
|
||||
html, body {
|
||||
font: 16pt arial;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background-color: {{backgroundColor}};
|
||||
}
|
||||
|
||||
#viz {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdn.neo4jlabs.com/neovis.js/v1.5.0/neovis.js"></script>
|
||||
</head>
|
||||
|
||||
<body onload="draw()">
|
||||
<div id="viz"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
let viz;
|
||||
|
||||
function draw() {
|
||||
const config = {
|
||||
container_id: "viz",
|
||||
server_url: `{{host}}`,
|
||||
server_user: `{{username}}`,
|
||||
server_database: `{{database}}`,
|
||||
server_password: `{{password}}`,
|
||||
encrypted: `{{encryption}}`,
|
||||
trust: `{{trust}}`,
|
||||
initial_cypher: `{{query}}`
|
||||
};
|
||||
|
||||
viz = new NeoVis.default(config);
|
||||
viz.render();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,603 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Graph</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: {{backgroundColor}}; }
|
||||
canvas { display: block; position: absolute; top: 0; left: 0; }
|
||||
|
||||
#popup {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
background: rgba(20, 20, 30, 0.95);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
font-family: -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-height: 40%;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
#popup .lbl {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: #7EC8E3;
|
||||
}
|
||||
#popup .prop { color: #ccc; }
|
||||
#popup .prop b { color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div id="popup"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var nodesData = {{nodes}};
|
||||
var edgesData = {{edges}};
|
||||
|
||||
if (nodesData.length === 0) {
|
||||
document.body.innerHTML = "<div style='padding:24px;font-family:-apple-system,sans-serif;color:#888;font-size:15px'>No graph data — query must return nodes.</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
var canvas = document.getElementById('c');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var popup = document.getElementById('popup');
|
||||
var W = 0, H = 0, dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// ---- node/edge state ----
|
||||
var nodes = nodesData.map(function (d, i) {
|
||||
return { id: d.id, label: d.label || String(d.id), title: d.title || '', color: d.color || '#5A99D4', x: 0, y: 0, vx: 0, vy: 0 };
|
||||
});
|
||||
var edges = edgesData.map(function (d) {
|
||||
return { from: d.from, to: d.to, label: d.label || '' };
|
||||
});
|
||||
var nodeById = {};
|
||||
nodes.forEach(function (n) { nodeById[n.id] = n; });
|
||||
|
||||
// ---- compute degree per node ----
|
||||
nodes.forEach(function (n) { n.degree = 0; });
|
||||
edges.forEach(function (e) {
|
||||
if (nodeById[e.from]) nodeById[e.from].degree++;
|
||||
if (nodeById[e.to]) nodeById[e.to].degree++;
|
||||
});
|
||||
var maxDegree = 1;
|
||||
nodes.forEach(function (n) { if (n.degree > maxDegree) maxDegree = n.degree; });
|
||||
|
||||
// ---- resize ----
|
||||
function resize() {
|
||||
W = document.documentElement.clientWidth || window.innerWidth || document.body.clientWidth || 300;
|
||||
H = document.documentElement.clientHeight || window.innerHeight || document.body.clientHeight || 400;
|
||||
canvas.style.width = W + 'px';
|
||||
canvas.style.height = H + 'px';
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
// place nodes in a circle around center
|
||||
var N = nodes.length;
|
||||
function placeInitial() {
|
||||
var r = Math.min(W, H) * (0.32 + Math.min(N / 200, 0.5));
|
||||
nodes.forEach(function (n, i) {
|
||||
var a = (2 * Math.PI * i) / N - Math.PI / 2;
|
||||
n.x = W / 2 + r * Math.cos(a);
|
||||
n.y = H / 2 + r * Math.sin(a);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- physics (scaled by node count) ----
|
||||
var large = N > 50;
|
||||
var BASE_REPULSION = large ? 15000 : 5000;
|
||||
var LEAF_SPRING = large ? 60 : 60;
|
||||
var HUB_SPRING = large ? 300 : 200;
|
||||
var SPRING_K = 0.03;
|
||||
var DAMP = large ? 0.65 : 0.8;
|
||||
var GRAVITY = large ? 0.003 : 0.008;
|
||||
var MAX_VEL = large ? 12 : 15;
|
||||
|
||||
// pre-tag hubs vs leaves for fast lookup
|
||||
var HUB_THRESHOLD = Math.max(3, maxDegree * 0.3);
|
||||
nodes.forEach(function (n) { n.isHub = n.degree >= HUB_THRESHOLD; });
|
||||
|
||||
function step() {
|
||||
var i, j, a, b, dx, dy, d, f;
|
||||
|
||||
// repulsion — much stronger between hubs to separate clusters
|
||||
var cutoff = large ? 1500 : Infinity;
|
||||
for (i = 0; i < N; i++) {
|
||||
for (j = i + 1; j < N; j++) {
|
||||
a = nodes[i]; b = nodes[j];
|
||||
dx = b.x - a.x; dy = b.y - a.y;
|
||||
d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
if (d > cutoff) continue;
|
||||
var rep = BASE_REPULSION;
|
||||
if (a.isHub && b.isHub) rep *= 3;
|
||||
else if (a.isHub || b.isHub) rep *= 1.5;
|
||||
f = rep / (d * d);
|
||||
var fx = f * dx / d, fy = f * dy / d;
|
||||
a.vx -= fx; a.vy -= fy;
|
||||
b.vx += fx; b.vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// spring attraction — short for leaves, long for hub-to-hub
|
||||
edges.forEach(function (e) {
|
||||
a = nodeById[e.from]; b = nodeById[e.to];
|
||||
if (!a || !b) return;
|
||||
// leaf-to-hub: short spring (leaf stays close as petal)
|
||||
// hub-to-hub: long spring (clusters separate)
|
||||
var len;
|
||||
if (a.isHub && b.isHub) {
|
||||
len = HUB_SPRING;
|
||||
} else {
|
||||
var hubDeg = Math.max(a.degree, b.degree);
|
||||
len = LEAF_SPRING + (HUB_SPRING - LEAF_SPRING) * 0.3 * (hubDeg / maxDegree);
|
||||
}
|
||||
dx = b.x - a.x; dy = b.y - a.y;
|
||||
d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
f = SPRING_K * (d - len);
|
||||
var fx = f * dx / d, fy = f * dy / d;
|
||||
a.vx += fx; a.vy += fy;
|
||||
b.vx -= fx; b.vy -= fy;
|
||||
});
|
||||
|
||||
// gravity toward center + damping + velocity cap
|
||||
nodes.forEach(function (n) {
|
||||
n.vx += (W / 2 - n.x) * GRAVITY;
|
||||
n.vy += (H / 2 - n.y) * GRAVITY;
|
||||
n.vx *= DAMP; n.vy *= DAMP;
|
||||
var v = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
||||
if (v > MAX_VEL) { n.vx *= MAX_VEL / v; n.vy *= MAX_VEL / v; }
|
||||
n.x += n.vx; n.y += n.vy;
|
||||
});
|
||||
}
|
||||
|
||||
// auto-fit: zoom and pan so all nodes are visible with padding
|
||||
function fitToView() {
|
||||
if (N === 0) return;
|
||||
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
nodes.forEach(function (n) {
|
||||
if (n.x < minX) minX = n.x;
|
||||
if (n.y < minY) minY = n.y;
|
||||
if (n.x > maxX) maxX = n.x;
|
||||
if (n.y > maxY) maxY = n.y;
|
||||
});
|
||||
var pad = NODE_R * 3;
|
||||
var gw = (maxX - minX) + pad * 2;
|
||||
var gh = (maxY - minY) + pad * 2;
|
||||
if (gw < 1) gw = 1;
|
||||
if (gh < 1) gh = 1;
|
||||
var newZoom = Math.min(W / gw, H / gh, 2);
|
||||
newZoom = Math.max(newZoom, 0.05);
|
||||
var cx = (minX + maxX) / 2;
|
||||
var cy = (minY + maxY) / 2;
|
||||
zoom = newZoom;
|
||||
panX = W / 2 - cx * zoom;
|
||||
panY = H / 2 - cy * zoom;
|
||||
}
|
||||
|
||||
// ---- camera (pan + zoom, initial zoom based on node count) ----
|
||||
var initialZoom = N <= 10 ? 1 : Math.max(0.05, 1 / Math.sqrt(N / 10));
|
||||
var panX = 0, panY = 0, zoom = initialZoom;
|
||||
|
||||
// screen coords -> world coords
|
||||
function toWorld(sx, sy) {
|
||||
return { x: (sx - panX) / zoom, y: (sy - panY) / zoom };
|
||||
}
|
||||
|
||||
// ---- draw ----
|
||||
var NODE_R = 20;
|
||||
var NODE_SEL = '#E8A838';
|
||||
var EDGE_CLR = '#666';
|
||||
var TEXT_CLR = '{{textColor}}';
|
||||
var selected = null;
|
||||
|
||||
function draw() {
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.translate(panX, panY);
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
// edges
|
||||
edges.forEach(function (e) {
|
||||
var a = nodeById[e.from], b = nodeById[e.to];
|
||||
if (!a || !b) return;
|
||||
var ang = Math.atan2(b.y - a.y, b.x - a.x);
|
||||
var tx = b.x - NODE_R * Math.cos(ang);
|
||||
var ty = b.y - NODE_R * Math.sin(ang);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(tx, ty);
|
||||
ctx.strokeStyle = EDGE_CLR;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// arrowhead
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, ty);
|
||||
ctx.lineTo(tx - 9 * Math.cos(ang - 0.4), ty - 9 * Math.sin(ang - 0.4));
|
||||
ctx.lineTo(tx - 9 * Math.cos(ang + 0.4), ty - 9 * Math.sin(ang + 0.4));
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = EDGE_CLR;
|
||||
ctx.fill();
|
||||
|
||||
if (e.label) {
|
||||
ctx.fillStyle = TEXT_CLR;
|
||||
ctx.font = '10px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(e.label, (a.x + b.x) / 2, (a.y + b.y) / 2 - 5);
|
||||
}
|
||||
});
|
||||
|
||||
// nodes
|
||||
nodes.forEach(function (n) {
|
||||
var isSel = n === selected;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, NODE_R, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = isSel ? NODE_SEL : n.color;
|
||||
ctx.fill();
|
||||
if (isSel) {
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
var lbl = n.label.length > 10 ? n.label.slice(0, 9) + '…' : n.label;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 11px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(lbl, n.x, n.y);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---- popup ----
|
||||
function showPopup(n) {
|
||||
selected = n;
|
||||
var html = '<div class="lbl">' + escHtml(n.label) + '</div>';
|
||||
if (n.title) {
|
||||
n.title.split(', ').forEach(function (pair) {
|
||||
var idx = pair.indexOf(': ');
|
||||
if (idx > -1) {
|
||||
html += '<div class="prop"><b>' + escHtml(pair.slice(0, idx)) + ':</b> ' + escHtml(pair.slice(idx + 2)) + '</div>';
|
||||
} else {
|
||||
html += '<div class="prop">' + escHtml(pair) + '</div>';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
html += '<div class="prop">No properties</div>';
|
||||
}
|
||||
popup.innerHTML = html;
|
||||
popup.style.display = 'block';
|
||||
draw();
|
||||
}
|
||||
|
||||
function hidePopup() {
|
||||
selected = null;
|
||||
popup.style.display = 'none';
|
||||
draw();
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ---- expand / collapse ----
|
||||
var lastTapTime = 0, lastTapNode = null;
|
||||
var expandedBy = {}; // nodeId -> [childIds added by expanding that node]
|
||||
|
||||
// initial nodes are never collapsible
|
||||
var initialNodeIds = {};
|
||||
nodes.forEach(function (n) { initialNodeIds[n.id] = true; });
|
||||
|
||||
function recomputeDegrees() {
|
||||
N = nodes.length;
|
||||
nodes.forEach(function (n) { n.degree = 0; });
|
||||
edges.forEach(function (e) {
|
||||
if (nodeById[e.from]) nodeById[e.from].degree++;
|
||||
if (nodeById[e.to]) nodeById[e.to].degree++;
|
||||
});
|
||||
maxDegree = 1;
|
||||
nodes.forEach(function (n) { if (n.degree > maxDegree) maxDegree = n.degree; });
|
||||
HUB_THRESHOLD = Math.max(3, maxDegree * 0.3);
|
||||
nodes.forEach(function (n) { n.isHub = n.degree >= HUB_THRESHOLD; });
|
||||
}
|
||||
|
||||
function toggleExpandCollapse(n) {
|
||||
if (expandedBy[n.id]) {
|
||||
collapseNode(n);
|
||||
} else {
|
||||
expandNode(n);
|
||||
}
|
||||
}
|
||||
|
||||
function expandNode(n) {
|
||||
var f = document.createElement('iframe');
|
||||
f.style.display = 'none';
|
||||
f.src = 'app://expand?nodeId=' + n.id;
|
||||
document.body.appendChild(f);
|
||||
setTimeout(function () { document.body.removeChild(f); }, 200);
|
||||
}
|
||||
|
||||
function collapseNode(n) {
|
||||
var childIds = expandedBy[n.id];
|
||||
if (!childIds) return;
|
||||
|
||||
// only remove children that aren't initial nodes and aren't
|
||||
// also owned by another still-expanded node
|
||||
var otherOwned = {};
|
||||
Object.keys(expandedBy).forEach(function (ownerId) {
|
||||
if (ownerId == n.id) return;
|
||||
expandedBy[ownerId].forEach(function (cid) { otherOwned[cid] = true; });
|
||||
});
|
||||
|
||||
var toRemove = {};
|
||||
childIds.forEach(function (cid) {
|
||||
if (!initialNodeIds[cid] && !otherOwned[cid]) toRemove[cid] = true;
|
||||
});
|
||||
|
||||
// also collapse any nodes that were expanded from nodes we're about to remove
|
||||
Object.keys(toRemove).forEach(function (rid) {
|
||||
if (expandedBy[rid]) {
|
||||
expandedBy[rid].forEach(function (cid) {
|
||||
if (!initialNodeIds[cid] && !otherOwned[cid]) toRemove[cid] = true;
|
||||
});
|
||||
delete expandedBy[rid];
|
||||
}
|
||||
});
|
||||
|
||||
// remove edges connected to removed nodes
|
||||
edges = edges.filter(function (e) {
|
||||
return !toRemove[e.from] && !toRemove[e.to];
|
||||
});
|
||||
|
||||
// remove nodes
|
||||
nodes = nodes.filter(function (nd) { return !toRemove[nd.id]; });
|
||||
Object.keys(toRemove).forEach(function (rid) { delete nodeById[rid]; });
|
||||
|
||||
delete expandedBy[n.id];
|
||||
|
||||
// hide popup if the selected node was removed
|
||||
if (selected && toRemove[selected.id]) hidePopup();
|
||||
|
||||
recomputeDegrees();
|
||||
resumeLoop();
|
||||
}
|
||||
|
||||
// called from C# via EvaluateJavaScriptAsync after expand query completes
|
||||
window.addGraphData = function (newNodes, newEdges, originId) {
|
||||
var origin = nodeById[originId];
|
||||
var ox = origin ? origin.x : W / 2;
|
||||
var oy = origin ? origin.y : H / 2;
|
||||
var addedIds = [];
|
||||
|
||||
newNodes.forEach(function (d) {
|
||||
if (nodeById[d.id]) {
|
||||
addedIds.push(d.id); // track even existing ones as children
|
||||
return;
|
||||
}
|
||||
var angle = Math.random() * 2 * Math.PI;
|
||||
var nd = {
|
||||
id: d.id, label: d.label || String(d.id), title: d.title || '',
|
||||
color: d.color || '#5A99D4',
|
||||
x: ox + (40 + Math.random() * 40) * Math.cos(angle),
|
||||
y: oy + (40 + Math.random() * 40) * Math.sin(angle),
|
||||
vx: 0, vy: 0, degree: 0
|
||||
};
|
||||
nodes.push(nd);
|
||||
nodeById[nd.id] = nd;
|
||||
addedIds.push(d.id);
|
||||
});
|
||||
|
||||
newEdges.forEach(function (d) {
|
||||
var exists = edges.some(function (e) {
|
||||
return e.from === d.from && e.to === d.to && e.label === (d.label || '');
|
||||
});
|
||||
if (exists) return;
|
||||
if (!nodeById[d.from] || !nodeById[d.to]) return;
|
||||
edges.push({ from: d.from, to: d.to, label: d.label || '' });
|
||||
});
|
||||
|
||||
expandedBy[originId] = addedIds;
|
||||
|
||||
recomputeDegrees();
|
||||
resumeLoop();
|
||||
};
|
||||
|
||||
// ---- interaction handling (touch + mouse/pointer) ----
|
||||
var ptrStartX, ptrStartY, ptrEndX, ptrEndY, ptrStartTime, dragging = null, panning = false, mouseDown = false;
|
||||
|
||||
function nodeAt(sx, sy) {
|
||||
var w = toWorld(sx, sy);
|
||||
for (var i = nodes.length - 1; i >= 0; i--) {
|
||||
var n = nodes[i];
|
||||
if (Math.hypot(n.x - w.x, n.y - w.y) <= (NODE_R + 8) / zoom) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function coordFromEvent(e) {
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var src = e.touches ? e.touches[0] : e;
|
||||
return { x: src.clientX - rect.left, y: src.clientY - rect.top };
|
||||
}
|
||||
|
||||
function handleStart(e) {
|
||||
if (e.touches) e.preventDefault();
|
||||
var p = coordFromEvent(e);
|
||||
ptrStartX = ptrEndX = p.x;
|
||||
ptrStartY = ptrEndY = p.y;
|
||||
ptrStartTime = Date.now();
|
||||
dragging = nodeAt(p.x, p.y);
|
||||
panning = !dragging;
|
||||
mouseDown = true;
|
||||
}
|
||||
|
||||
function handleMove(e) {
|
||||
if (e.touches) e.preventDefault();
|
||||
if (!mouseDown) return;
|
||||
var p = coordFromEvent(e);
|
||||
var dx = p.x - ptrEndX, dy = p.y - ptrEndY;
|
||||
ptrEndX = p.x; ptrEndY = p.y;
|
||||
if (panning) {
|
||||
panX += dx; panY += dy;
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
if (!dragging) return;
|
||||
var w = toWorld(p.x, p.y);
|
||||
dragging.x = w.x; dragging.y = w.y;
|
||||
dragging.vx = 0; dragging.vy = 0;
|
||||
draw();
|
||||
}
|
||||
|
||||
function handleEnd(e) {
|
||||
if (e.touches) e.preventDefault();
|
||||
if (!mouseDown) return;
|
||||
var dt = Date.now() - ptrStartTime;
|
||||
var moved = Math.hypot(ptrEndX - ptrStartX, ptrEndY - ptrStartY) > 8;
|
||||
|
||||
if (!moved && dt < 300) {
|
||||
var hit = nodeAt(ptrStartX, ptrStartY);
|
||||
var now = Date.now();
|
||||
// double-tap detection — toggle expand/collapse
|
||||
if (hit && hit === lastTapNode && (now - lastTapTime) < 400) {
|
||||
lastTapNode = null; lastTapTime = 0;
|
||||
toggleExpandCollapse(hit);
|
||||
} else if (hit) {
|
||||
lastTapNode = hit; lastTapTime = now;
|
||||
if (hit === selected) hidePopup();
|
||||
else showPopup(hit);
|
||||
} else {
|
||||
lastTapNode = null; lastTapTime = 0;
|
||||
hidePopup();
|
||||
}
|
||||
}
|
||||
|
||||
dragging = null;
|
||||
panning = false;
|
||||
mouseDown = false;
|
||||
resumeLoop();
|
||||
}
|
||||
|
||||
// touch events
|
||||
canvas.addEventListener('touchstart', handleStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', handleMove, { passive: false });
|
||||
canvas.addEventListener('touchend', handleEnd, { passive: false });
|
||||
|
||||
// mouse events (for simulator / desktop WebView)
|
||||
canvas.addEventListener('mousedown', handleStart);
|
||||
canvas.addEventListener('mousemove', handleMove);
|
||||
canvas.addEventListener('mouseup', handleEnd);
|
||||
|
||||
// dismiss popup when tapping/clicking it
|
||||
popup.addEventListener('touchend', function (e) { e.stopPropagation(); hidePopup(); });
|
||||
popup.addEventListener('click', function (e) { e.stopPropagation(); hidePopup(); });
|
||||
|
||||
// ---- zoom: scroll wheel ----
|
||||
canvas.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
var newZoom = Math.min(Math.max(zoom * factor, 0.1), 10);
|
||||
// zoom toward mouse position
|
||||
panX = mx - (mx - panX) * (newZoom / zoom);
|
||||
panY = my - (my - panY) * (newZoom / zoom);
|
||||
zoom = newZoom;
|
||||
draw();
|
||||
}, { passive: false });
|
||||
|
||||
// ---- zoom: pinch (two-finger touch) ----
|
||||
var pinchStartDist = 0, pinchStartZoom = 1;
|
||||
|
||||
canvas.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
var dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
var dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
pinchStartDist = Math.hypot(dx, dy) || 1;
|
||||
pinchStartZoom = zoom;
|
||||
dragging = null;
|
||||
panning = false;
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchmove', function (e) {
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
var dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
var dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
var dist = Math.hypot(dx, dy) || 1;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||||
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||
var newZoom = Math.min(Math.max(pinchStartZoom * (dist / pinchStartDist), 0.1), 10);
|
||||
panX = mx - (mx - panX) * (newZoom / zoom);
|
||||
panY = my - (my - panY) * (newZoom / zoom);
|
||||
zoom = newZoom;
|
||||
draw();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// ---- animation loop ----
|
||||
var ticks = 0, rafId = null;
|
||||
var maxTicks = large ? 250 : 400;
|
||||
|
||||
function loop() {
|
||||
step();
|
||||
draw();
|
||||
ticks++;
|
||||
if (ticks < maxTicks) rafId = requestAnimationFrame(loop);
|
||||
else rafId = null;
|
||||
}
|
||||
|
||||
function resumeLoop() {
|
||||
ticks = 0;
|
||||
if (!rafId) rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
// ---- init ----
|
||||
var initRetries = 0;
|
||||
|
||||
function init() {
|
||||
resize();
|
||||
// WebView may not have layout yet — retry until we get real dimensions
|
||||
if ((W <= 1 || H <= 1) && initRetries < 20) {
|
||||
initRetries++;
|
||||
setTimeout(init, 50);
|
||||
return;
|
||||
}
|
||||
placeInitial();
|
||||
// center the camera on the graph with the pre-computed zoom
|
||||
panX = W / 2 - (W / 2) * zoom;
|
||||
panY = H / 2 - (H / 2) * zoom;
|
||||
loop();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
resize();
|
||||
draw();
|
||||
});
|
||||
|
||||
// Use rAF to ensure the browser has laid out the canvas before we read its size
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(init);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,8 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<TargetFramework>net10.0-ios</TargetFramework>
|
||||
<UseMaui>true</UseMaui>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>disable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
@@ -11,18 +13,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Acr.UserDialogs" Version="7.2.0.534" />
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.20" />
|
||||
<PackageReference Include="Neo4jClient" Version="4.1.18" />
|
||||
<PackageReference Include="Xamarin.Essentials" Version="1.7.0" />
|
||||
<PackageReference Include="Xamarin.Forms" Version="5.0.0.2196" />
|
||||
<PackageReference Include="Xamarin.Forms.PancakeView" Version="2.3.0.759" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Visualization\neovis.html" />
|
||||
<EmbeddedResource Include="Visualization\neovis.html" />
|
||||
<None Remove="Resources\licenses.json" />
|
||||
<EmbeddedResource Include="Resources\licenses.json" />
|
||||
<None Remove="Assets\Fonts\RobotoMono-Regular.ttf" />
|
||||
<None Remove="Visualization\visgraph.html" />
|
||||
<EmbeddedResource Include="Visualization\visgraph.html" />
|
||||
<None Remove="Resources\licenses.json" />
|
||||
<EmbeddedResource Include="Resources\licenses.json" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user