.NET MAUI upgrade

This commit is contained in:
Trevi Awater
2026-03-30 20:58:37 +02:00
parent 984bfce33a
commit 3473744e71
66 changed files with 1657 additions and 688 deletions

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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>

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

View File

@@ -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))]

View File

@@ -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))]

View File

@@ -8,7 +8,7 @@
//
using Foundation;
using Xamarin.Forms;
using Microsoft.Maui.Controls;
using Xamarin.Neo4j.iOS.Services;
using Xamarin.Neo4j.Services.Interfaces;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}
}
}

View File

@@ -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">

View File

@@ -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
{

View File

@@ -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>

View File

@@ -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
{

View File

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

View File

@@ -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" />

View File

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

View File

@@ -9,7 +9,7 @@
using System;
using System.Globalization;
using Xamarin.Forms;
using Microsoft.Maui.Controls;
using Xamarin.Neo4j.Managers;
using Xamarin.Neo4j.Models;

View File

@@ -10,7 +10,7 @@
using System;
using System.Globalization;
using Newtonsoft.Json;
using Xamarin.Forms;
using Microsoft.Maui.Controls;
namespace Xamarin.Neo4j.Converters
{

View File

@@ -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;

View File

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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

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

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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">

View File

@@ -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
{

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>

View File

@@ -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

View File

@@ -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}"

View File

@@ -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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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
{

View File

@@ -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">

View File

@@ -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
{

View File

@@ -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>

View File

@@ -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
{

View File

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

View File

@@ -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;

View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -9,7 +9,7 @@
using System.Collections.Generic;
using System.Windows.Input;
using Xamarin.Forms;
using Microsoft.Maui.Controls;
namespace Xamarin.Neo4j.ViewModels
{

View File

@@ -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>

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ---- 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>

View File

@@ -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>