38 Commits

Author SHA1 Message Date
Trevi Awater
1711d22287 Fix App Store deploy: use macos-26 runner with Xcode 26.5
The unpinned maui-ios workload now installs the iOS 26.5 SDK manifest,
which requires the iOS 26.4+ SDK. The macos-15 image tops out at
Xcode 26.3, so move to macos-26 and select Xcode 26.5 to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 13:34:27 +02:00
Trevi Awater
1c319e6619 Merge pull request #2 from resoftware/obfuscate-dex-with-r8
Obfuscate the Android DEX with R8
2026-09-17 23:07:33 +02:00
Trevi Awater
fb361f0f4c Obfuscate the Android DEX with R8
Mirrors lojack-connect. .NET for Android runs no DEX shrinker by default, and
even with AndroidLinkTool=r8 the ProGuard config the SDK generates hardcodes
-dontobfuscate -- a flag nothing later in the config list can undo -- so Play
Console's "DEX code optimization" report scores obfuscation at ~1%.

_AndroidObfuscateDex drops the SDK's proguard_xamarin.cfg from R8's --pg-conf
list and substitutes a copy without that line, regenerating the
-printmapping/-keepattributes tail the SDK would have appended (absolute path:
R8 resolves a relative -printmapping against the config file's own directory).
49.8% of DEX classes are now renamed.

proguard.cfg carries the keeps the generated rules miss: classes the .NET
runtime resolves by name from native code (net.dot.android.**), and public and
protected fields, which the trimmer covers for methods only. The target also
caches aapt2's keep rules outside FileWrites -- the SDK adds them to
@(ProguardConfiguration) inside _CreateBaseApkWithAapt2 and registers the file
as a FileWrite, so an incremental Release build that skips the resource link
loses them and ships an APK that dies inflating FitWindowsFrameLayout.

Verified on a Pixel 9 emulator (API 36) with the obfuscated Release APK:
launch, connection list, Add Connection form, and a Bolt connection attempt
that exercises the network/TLS stack and surfaces the driver's error dialog.

The AAB carries mapping.txt, so Play retraces Java stacks itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:58:36 +02:00
Gerwin Kuijntjes
927e7627a6 chore: upgrade landing/ Astro v6→v7 2026-06-24 07:52:36 +02:00
Gerwin Kuijntjes
14d755f8bb Enable compiled XAML bindings and fix MAUI deprecation warnings
Add x:DataType to all pages and controls for compiled bindings, migrate
from deprecated Application.Current.MainPage to Windows[0].Page API,
suppress expected analyzer warnings (XC0023, CS0618, CA1422), and add
Newtonsoft.Json dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:32:26 +02:00
Gerwin Kuijntjes
fb504a88c2 Fix Play Store pipeline: set changesNotSentForReview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 07:53:17 +02:00
Gerwin Kuijntjes
27e3d4a96d Fix crash on Add Connection: use JavaCast for Android trust manager lookup
OfType<IX509TrustManager>() returns empty on Android because CLR type
checking does not work against Java proxy objects. JavaCast<> is the
correct API for casting Java-backed objects in MAUI Android bindings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 07:48:32 +02:00
Gerwin Kuijntjes
b1ddcaa849 Make footer links vertical on mobile only
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:07:54 +02:00
Gerwin Kuijntjes
d1a96c5c67 Make footer links vertical and nav download scroll to CTA
- Footer links now stack vertically instead of horizontal row
- Nav "Download" button scrolls to the #download CTA section
  instead of linking directly to the App Store

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:06:09 +02:00
Gerwin Kuijntjes
6970ea40f3 Add Cloudflare Pages preview builds for landing PRs
Triggers a preview deployment on pull requests that touch landing/
files. Posts the preview URL as a comment on the PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:44:40 +02:00
Gerwin Kuijntjes
a2eed3c38b Add Android support to landing page
Replace all iOS-only references with iOS + Android across the landing
page, meta tags, footer, and privacy policy. Convert Play Store
"Coming Soon" placeholders into active Google Play links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:42:51 +02:00
Gerwin Kuijntjes
d099f72398 Fix Play Store workflow YAML syntax, extract version script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:39:40 +02:00
Gerwin Kuijntjes
8141e55521 Add Play Store deploy workflow and bump Android version to 2.0.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:38:05 +02:00
Trevi Awater
c5403313d9 Update README.md 2026-04-02 13:26:29 +02:00
Trevi Awater
ea24c9eed5 updated screenshots 2026-04-02 13:24:11 +02:00
Trevi Awater
edeac88a92 Merge branch 'main' of github.com:awatertrevi/xamarin-neo4j 2026-04-02 12:44:21 +02:00
Trevi Awater
1785fac843 fixes ios 2026-04-02 12:44:17 +02:00
Gerwin Kuijntjes
eadc2d6606 Fix: FAB white background with dark icon in dark mode
Add FabBackgroundColor / FabTextColor theme keys so the FAB is always
visible: dark background + white icon in light mode, white background +
dark icon in dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:31:05 +02:00
Gerwin Kuijntjes
af37fb7095 Fix: connection card host truncation and badge layout
Move the host label to its own line so it truncates properly before
the edit/delete buttons. Move the lock icon and scheme badge pill to
a separate third line below the host.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:28:36 +02:00
Gerwin Kuijntjes
0e162ddcd5 Fix: symbol bar keys invisible in dark mode
Switch symbol key widgets from Button to TextView to bypass Android
Material theme button text-color overrides, giving full control over
the key label color in both light and dark themes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:28:36 +02:00
Trevi Awater
66604f72a5 Merge branch 'main' of github.com:awatertrevi/xamarin-neo4j 2026-04-02 12:26:12 +02:00
Trevi Awater
b709c7b5ce fix 2026-04-02 12:26:07 +02:00
Gerwin Kuijntjes
f37c09a32a Fix: restore full graph interaction when node detail panel is open
- Remove backdrop inset:0 that was intercepting all canvas touches;
  backdrop is now pointer-events:none and only covers the area above
  the panel (bottom: 55%) so touch events reach the canvas
- Replace blanket `if (panelOpen) return` in handleStart with a guard
  that only skips the two-finger pinch (handled by its own listener),
  restoring single-finger pan, node tap, and pinch-zoom while panel
  is open
- Add user-scalable=no viewport flag to prevent WebView native zoom
  from scaling the panel UI
- Add singleTapTimer for double-tap detection: single tap delays 350ms
  so double-tap is distinguished and only triggers expand/collapse

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:25:09 +02:00
Gerwin Kuijntjes
c0b3728ceb Fix: Re:Software logo invisible on dark mode in settings
Wrap the logo image in a white rounded Border so it remains visible
regardless of the system theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:25:01 +02:00
Gerwin Kuijntjes
528a8ba3a3 Fix: Android back button tint, MauiImage resources for logos
- Tint toolbar navigation (back) icon white alongside overflow icon
- Add resoftware.png and logo.png as MauiImage resources (were only AndroidAssets)
- Copy resoftware.png to Android Resources for accessibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:52:26 +02:00
Gerwin Kuijntjes
d7a1a1e4ee Feature: settings toggles, editable connection name, settings layout improvements
Settings:
- Add Editor section with auto-capitalize and auto-zoom toggles (blue when on)
- Reorder sections: Editor, Data, Support, About
- Move footer ("A product of" + logo) inside scroll instead of fixed at bottom

AddConnection:
- Add editable Name field at top of form
- Remove colons from field labels
- Save uses Name field instead of popup prompt
- Name falls back to host if left empty
- Fix UpdateConnectionString to persist Name field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:52:14 +02:00
Gerwin Kuijntjes
eefb7fc0ea Design: dashboard-style ConnectionsPage with rich connection cards
- Add header with ProjectDiagram icon, app title, and connection count
- Replace plain ListView with styled cards (Border + rounded corners)
- Each card shows: database icon, name, host, scheme badge, secure lock
- Inline edit/delete buttons on each card (pencil + trash icons)
- Hide nav bar title (dashboard header replaces it)
- Add computed properties: IsSecure, SchemeShortName, DisplayName
- Add ConnectionCountLabel to ViewModel
- Add OpenConnection command for card tap handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:52:01 +02:00
Gerwin Kuijntjes
7df3395c3e Feature: improved symbols bar on Android, auto-capitalize keywords on both platforms
Android symbols bar:
- Rewrite using GlobalLayout + GetWindowVisibleDisplayFrame for reliable keyboard detection
- Toolbar attached once to content view, toggled via visibility (fixes crash)
- Pill-shaped container with themed colors, scrollable symbol strip
- Blue circle play icon (centered TextView), disabled when query empty
- Add symbols: { } \" ' . = * $

iOS symbols bar:
- Add same extra symbols to match Android
- Hide accessory when keyboard dismissed via external gesture

Auto-capitalize (both platforms):
- Cypher keywords auto-uppercased after cursor moves past them
- Respects auto_capitalize preference from Settings
- Skips capitalization while cursor is at end of word (no fighting while typing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:51:47 +02:00
Gerwin Kuijntjes
8eb4e3cd5e Feature: redesign graph node panel with slide-up sheet, actions, and auto-zoom
- Replace basic popup with animated slide-up panel using app theme colors
- Add action buttons: Copy text, Show JSON (inline with copy), Relations list
- Panel is bi-directional draggable: swipe down to dismiss, drag up to expand
- Panel view states: properties, JSON, relations with navigation between them
- Auto-zoom and center on node tap (accounts for panel height)
- Delayed zoom after expand so physics can settle nodes first
- Notify C# on panel open/close to expand inline WebView height
- Add backdrop overlay to dismiss panel on outside tap
- Toast notification for clipboard copies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:51:31 +02:00
Gerwin Kuijntjes
9fde77ca67 Design: improve JSON view readability with app-consistent theme colors
- Replace hardcoded VS Code-like muted colors with CSS variables
- Use app theme palette for backgrounds, text, strings, numbers, keys
- Add font-weight to keys, increase font size and line-height
- Increase bracket/comma opacity for better readability
- Dynamic theme switching via setTheme() updates all new variables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:51:18 +02:00
Gerwin Kuijntjes
35e81a8cb9 Design: polish result cards, empty state centering, FAB and nav bar colors
- Center no-results placeholder vertically by overlaying on Grid row
- Reduce result card height: smaller action buttons, halved graph view
- Add NavigationBarColor theme resource, use for FAB and nav bar
- Fix ApplyNavBarColors to read from theme instead of hardcoding #31333b
- Make title bar play button blue (#007AFF)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:51:03 +02:00
Trevi Awater
bf88052d53 fix 2026-04-01 17:51:23 +02:00
Ducky
e4c9b3f8b2 Merge remote-tracking branch 'origin/redesign-and-android' 2026-03-31 14:32:33 +00:00
Trevi Awater
6235e54d56 cleanup 2026-03-31 13:44:03 +02:00
Trevi Awater
171c2e40c0 fix 2026-03-31 13:19:21 +02:00
Trevi Awater
4407d0883c fix 2026-03-31 13:13:57 +02:00
Trevi Awater
fd5426385e fix 2026-03-31 12:50:07 +02:00
Trevi Awater
e2e5f7d3b1 landing page 2026-03-31 12:46:39 +02:00
72 changed files with 10886 additions and 452 deletions

17
.github/scripts/get_version_code.py vendored Normal file
View File

@@ -0,0 +1,17 @@
import json, os
from googleapiclient.discovery import build
from google.oauth2 import service_account
creds = service_account.Credentials.from_service_account_info(
json.loads(os.environ['PLAY_SERVICE_ACCOUNT_JSON']),
scopes=['https://www.googleapis.com/auth/androidpublisher']
)
service = build('androidpublisher', 'v3', credentials=creds)
edit = service.edits().insert(packageName='nl.resoftware.pocketgraph', body={}).execute()
bundles = service.edits().bundles().list(
packageName='nl.resoftware.pocketgraph',
editId=edit['id']
).execute()
service.edits().delete(packageName='nl.resoftware.pocketgraph', editId=edit['id']).execute()
max_code = max([b['versionCode'] for b in bundles.get('bundles', [])], default=0)
print(max_code + 1)

View File

@@ -11,7 +11,7 @@ on:
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: macos-15 runs-on: macos-26
timeout-minutes: 45 timeout-minutes: 45
steps: steps:
@@ -21,7 +21,7 @@ jobs:
- name: Select Xcode - name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1 uses: maxim-lobanov/setup-xcode@v1
with: with:
xcode-version: "26.2" xcode-version: "26.5"
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4

57
.github/workflows/deploy-landing.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Deploy Landing to Cloudflare Pages
on:
push:
branches: ["main"]
paths:
- "landing/**"
pull_request:
paths:
- "landing/**"
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: landing/package-lock.json
- name: Install dependencies
working-directory: landing
run: npm ci
- name: Build
working-directory: landing
run: npm run build
- name: Deploy to Cloudflare Pages
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=pocketgraph-app --branch=${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main' }}
workingDirectory: landing
- name: Comment preview URL on PR
if: github.event_name == 'pull_request'
uses: thollander/actions-comment-pull-request@v3
with:
message: |
🔍 **Preview deployment ready!**
${{ steps.deploy.outputs.deployment-url }}
comment-tag: cloudflare-preview

71
.github/workflows/deploy-playstore.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Deploy to Play Store
on:
push:
branches: ["main"]
paths:
- "Xamarin.Neo4j/**"
release:
types: [published]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
timeout-minutes: 45
environment: android-prod
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Setup JDK
uses: actions/setup-java@v4
with:
distribution: microsoft
java-version: "21"
- name: Install MAUI Android workload
run: dotnet workload install maui-android
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > ${{ runner.temp }}/pocketgraph.jks
- name: Get next version code from Play Store
id: version
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
pip install google-api-python-client google-auth --quiet
VERSION_CODE=$(python3 .github/scripts/get_version_code.py)
sed -i "s/android:versionCode=\"[^\"]*\"/android:versionCode=\"$VERSION_CODE\"/" \
Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.Android/Properties/AndroidManifest.xml
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
echo "Version code: $VERSION_CODE"
- name: Build signed AAB
working-directory: Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.Android
run: |
dotnet publish -f net10.0-android -c Release \
-p:AndroidPackageFormat=aab \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore=${{ runner.temp }}/pocketgraph.jks \
-p:AndroidSigningKeyAlias=${{ secrets.KEYSTORE_ALIAS }} \
-p:AndroidSigningStorePass=${{ secrets.KEYSTORE_STORE_PASS }} \
-p:AndroidSigningKeyPass=${{ secrets.KEYSTORE_KEY_PASS }}
- name: Upload to Play Store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: nl.resoftware.pocketgraph
releaseFiles: Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.Android/bin/Release/net10.0-android/publish/*-Signed.aab
track: internal
status: completed
changesNotSentForReview: true
whatsNewDirectory: Xamarin.Neo4j/distribution/whatsnew

View File

@@ -1,6 +1,8 @@
# PocketGraph # PocketGraph
A mobile Neo4j and Memgraph client for iOS, built with .NET MAUI. A Neo4j and Memgraph client for iPhone and iPad, built with .NET MAUI.
**[pocketgraph.app](https://pocketgraph.app)**
[![Download on the App Store](https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us?size=250x83&amp;releaseDate=1280278400)](https://apps.apple.com/nl/app/pocketgraph/id1604368926) [![Download on the App Store](https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us?size=250x83&amp;releaseDate=1280278400)](https://apps.apple.com/nl/app/pocketgraph/id1604368926)
@@ -48,6 +50,36 @@ dotnet test Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.Tests/
dotnet test Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.IntegrationTests/ dotnet test Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.IntegrationTests/
``` ```
### Android DEX shrinking & obfuscation (R8)
Release builds of the Android head run R8 over the Java/DEX side
(`AndroidLinkTool=r8` in `Xamarin.Neo4j.Android.csproj`). Play Console's *DEX code
optimization* report scores obfuscation at ~1% on a stock .NET MAUI build: .NET for
Android runs no DEX shrinker by default, and even with R8 on, the ProGuard config the
SDK generates hardcodes `-dontobfuscate` — a flag nothing later in the config list can
undo. The `_AndroidObfuscateDex` target therefore drops that file from R8's `--pg-conf`
list and substitutes `Xamarin.Neo4j.Android/proguard_xamarin.cfg` (the same file minus
that line), plus a generated file carrying the `-printmapping`/`-keepattributes` tail
the SDK would have appended. Measured on the Release APK: **49.8% of DEX classes
renamed, up from ~0%**.
Only library-internal classes are renamed: the trimmer emits a `-keep` rule for every
Java type the managed bindings reference, aapt2 one for every class named in a layout
or the manifest, and each Android Callable Wrapper gets its own.
`Xamarin.Neo4j.Android/proguard.cfg` covers what those miss — things resolved by name
from native code. Add to it if a Release build dies with `ClassNotFoundException`,
`NoSuchFieldError` or `NoSuchMethodError` where a Debug build does not.
`mapping.txt` is embedded in the AAB
(`BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map`), so Play Console
retraces obfuscated Java stacks by itself. Managed (C#) stack traces are unaffected —
R8 only touches DEX.
Local Release builds must be clean: aapt2's keep rules are registered as a `FileWrite`,
so an incremental build that skips the resource link has `IncrementalClean` delete them.
The target caches a copy outside `FileWrites` and errors out if even that is missing —
delete `Xamarin.Neo4j.Android/obj/Release` if it fires.
## Technology Stack ## Technology Stack
- .NET MAUI - .NET MAUI

View File

@@ -8,12 +8,15 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Android.Content; using Android.Content;
using Android.Content.Res;
using Android.Graphics; using Android.Graphics;
using Android.Graphics.Drawables;
using Android.Text; using Android.Text;
using Android.Text.Style; using Android.Text.Style;
using Android.Views; using Android.Views;
using Android.Views.InputMethods; using Android.Views.InputMethods;
using Android.Widget; using Android.Widget;
using AndroidX.Core.View;
using Microsoft.Maui.Handlers; using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform; using Microsoft.Maui.Platform;
using Xamarin.Neo4j.Controls; using Xamarin.Neo4j.Controls;
@@ -49,6 +52,9 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
"FALSE", "NULL", "TRUE" "FALSE", "NULL", "TRUE"
]; ];
private LinearLayout _toolbar;
private bool _toolbarAttached;
protected override void ConnectHandler(MauiAppCompatEditText platformView) protected override void ConnectHandler(MauiAppCompatEditText platformView)
{ {
base.ConnectHandler(platformView); base.ConnectHandler(platformView);
@@ -58,43 +64,136 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
| InputTypes.TextFlagMultiLine | InputTypes.TextFlagMultiLine
| InputTypes.TextFlagNoSuggestions; | InputTypes.TextFlagNoSuggestions;
// Keyboard toolbar with symbol keys and Execute button // Build the keyboard toolbar
var toolbar = BuildToolbar(platformView); _toolbar = BuildToolbar(platformView);
// Attach toolbar once to the content view (stays in hierarchy, just hidden)
AttachToolbarOnce(platformView);
// Detect keyboard via visible frame height difference.
// Edge-to-edge and varying MAUI soft-input modes make WindowInsets unreliable,
// so we compare the root view height to the visible display frame.
platformView.ViewTreeObserver.GlobalLayout += (s, e) =>
{
var rootView = platformView.RootView;
if (rootView == null) return;
var rect = new global::Android.Graphics.Rect();
rootView.GetWindowVisibleDisplayFrame(rect);
var screenHeight = rootView.Height;
var keyboardHeight = screenHeight - rect.Bottom;
if (keyboardHeight > screenHeight * 0.15 && platformView.HasFocus)
{
if (_toolbar.LayoutParameters is FrameLayout.LayoutParams lp)
{
lp.BottomMargin = keyboardHeight;
_toolbar.LayoutParameters = lp;
}
_toolbar.Visibility = ViewStates.Visible;
}
else
{
_toolbar.Visibility = ViewStates.Gone;
}
};
// Also hide on focus loss
platformView.FocusChange += (s, e) => platformView.FocusChange += (s, e) =>
toolbar.Visibility = e.HasFocus ? ViewStates.Visible : ViewStates.Gone; {
if (!e.HasFocus)
_toolbar.Visibility = ViewStates.Gone;
};
// Syntax highlighting // Syntax highlighting
platformView.AddTextChangedListener(new CypherTextWatcher(platformView, _keyWords)); platformView.AddTextChangedListener(new CypherTextWatcher(platformView, _keyWords));
HighlightSyntax(platformView, _keyWords); HighlightSyntax(platformView, _keyWords);
} }
private void AttachToolbarOnce(MauiAppCompatEditText platformView)
{
if (_toolbarAttached) return;
var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity;
var decorView = activity?.Window?.DecorView as ViewGroup;
var contentView = decorView?.FindViewById<FrameLayout>(global::Android.Resource.Id.Content);
if (contentView == null) return;
var layoutParams = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MatchParent,
ViewGroup.LayoutParams.WrapContent,
GravityFlags.Bottom);
contentView.AddView(_toolbar, layoutParams);
_toolbar.Visibility = ViewStates.Gone;
_toolbarAttached = true;
}
private LinearLayout BuildToolbar(MauiAppCompatEditText platformView) private LinearLayout BuildToolbar(MauiAppCompatEditText platformView)
{ {
var context = platformView.Context; var context = platformView.Context;
var density = context.Resources.DisplayMetrics.Density;
var toolbar = new LinearLayout(context) var toolbar = new LinearLayout(context)
{ {
Orientation = Orientation.Horizontal, Orientation = global::Android.Widget.Orientation.Horizontal,
LayoutParameters = new LinearLayout.LayoutParams( LayoutParameters = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent,
ViewGroup.LayoutParams.WrapContent) (int)(48 * density))
}; };
toolbar.SetBackgroundColor(Color.ParseColor("#F2F2F7"));
toolbar.SetPadding(8, 8, 8, 8);
toolbar.Visibility = ViewStates.Gone;
// Scrollable symbol key strip // Theme-aware background
var isDark = (context.Resources.Configuration.UiMode & UiMode.NightMask) == UiMode.NightYes;
toolbar.SetBackgroundColor(Color.ParseColor(isDark ? "#141414" : "#F2F2F7"));
toolbar.SetPadding((int)(6 * density), (int)(6 * density), (int)(6 * density), (int)(6 * density));
toolbar.SetGravity(GravityFlags.CenterVertical);
// Pill container for symbol keys
var pillContainer = new LinearLayout(context)
{
Orientation = global::Android.Widget.Orientation.Horizontal,
LayoutParameters = new LinearLayout.LayoutParams(
0, (int)(36 * density), 1f)
};
var pillBg = new GradientDrawable();
pillBg.SetCornerRadius(18 * density);
pillBg.SetColor(Color.ParseColor(isDark ? "#2a2a2a" : "#E8E8ED").ToArgb());
pillContainer.Background = pillBg;
pillContainer.SetGravity(GravityFlags.CenterVertical);
// Scrollable symbol key strip inside pill
var scrollView = new HorizontalScrollView(context) var scrollView = new HorizontalScrollView(context)
{ {
LayoutParameters = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f) LayoutParameters = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)
}; };
scrollView.HorizontalScrollBarEnabled = false; scrollView.HorizontalScrollBarEnabled = false;
var keyRow = new LinearLayout(context) { Orientation = Orientation.Horizontal }; var keyRow = new LinearLayout(context)
foreach (var key in new[] { "(", ")", "[", "]", ":", "-", "->", "<-" }) {
Orientation = global::Android.Widget.Orientation.Horizontal,
LayoutParameters = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WrapContent, ViewGroup.LayoutParams.MatchParent)
};
keyRow.SetGravity(GravityFlags.CenterVertical);
var symbolTextColor = Color.ParseColor(isDark ? "#e2e2e2" : "#0c0c0c");
foreach (var key in new[] { "(", ")", "[", "]", "{", "}", ":", "-", "->", "<-", "\"", "'", ".", "=", "*", "$" })
{ {
var captured = key; var captured = key;
var btn = CreatePillButton(context, captured); var btn = new TextView(context)
{
Text = captured,
Gravity = GravityFlags.Center,
Clickable = true,
Focusable = true,
LayoutParameters = new LinearLayout.LayoutParams(
(int)(40 * density), ViewGroup.LayoutParams.MatchParent)
};
btn.SetTextColor(symbolTextColor);
btn.SetBackgroundColor(Color.Transparent);
btn.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 15);
btn.SetPadding(0, 0, 0, 0);
btn.SetIncludeFontPadding(false);
btn.Click += (s, e) => btn.Click += (s, e) =>
{ {
var start = Math.Max(platformView.SelectionStart, 0); var start = Math.Max(platformView.SelectionStart, 0);
@@ -105,12 +204,34 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
} }
scrollView.AddView(keyRow); scrollView.AddView(keyRow);
toolbar.AddView(scrollView); pillContainer.AddView(scrollView);
toolbar.AddView(pillContainer);
// Execute button // Spacer
var executeBtn = CreatePillButton(context, "Execute"); var spacer = new global::Android.Views.View(context)
{
LayoutParameters = new LinearLayout.LayoutParams((int)(6 * density), 0)
};
toolbar.AddView(spacer);
// Execute button — blue circle with play icon (▶)
var executeBtn = new TextView(context)
{
Text = "\u25B6",
LayoutParameters = new LinearLayout.LayoutParams(
(int)(36 * density), (int)(36 * density)),
Gravity = GravityFlags.Center,
Clickable = true,
Focusable = true
};
executeBtn.SetTextColor(Color.White); executeBtn.SetTextColor(Color.White);
executeBtn.SetBackgroundColor(Color.ParseColor("#007AFF")); executeBtn.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 18);
var executeBg = new GradientDrawable();
executeBg.SetCornerRadius(18 * density);
executeBg.SetColor(Color.ParseColor("#007AFF").ToArgb());
executeBtn.Background = executeBg;
executeBtn.SetPadding(0, 0, 0, 0);
executeBtn.SetIncludeFontPadding(false);
executeBtn.Click += (s, e) => executeBtn.Click += (s, e) =>
{ {
if (VirtualView is QueryEditor queryEditor) if (VirtualView is QueryEditor queryEditor)
@@ -120,33 +241,23 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
imm?.HideSoftInputFromWindow(platformView.WindowToken, 0); imm?.HideSoftInputFromWindow(platformView.WindowToken, 0);
} }
}; };
// Disable run button when query is empty
void updateRunEnabled()
{
var hasText = !string.IsNullOrWhiteSpace(platformView.Text);
executeBtn.Enabled = hasText;
executeBtn.Alpha = hasText ? 1f : 0.35f;
}
updateRunEnabled();
platformView.AddTextChangedListener(new SimpleTextWatcher(updateRunEnabled));
toolbar.AddView(executeBtn); toolbar.AddView(executeBtn);
// Attach toolbar to the parent view hierarchy toolbar.Visibility = ViewStates.Gone;
platformView.ViewTreeObserver.GlobalLayout += (s, e) =>
{
if (platformView.Parent is ViewGroup parent && toolbar.Parent == null)
parent.AddView(toolbar);
};
return toolbar; return toolbar;
} }
private static Button CreatePillButton(Context context, string label)
{
return new Button(context)
{
Text = label,
LayoutParameters = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WrapContent,
ViewGroup.LayoutParams.WrapContent)
{
MarginStart = 4,
MarginEnd = 4
}
};
}
// ── Syntax highlighting ─────────────────────────────────────────────── // ── Syntax highlighting ───────────────────────────────────────────────
internal static void HighlightSyntax(MauiAppCompatEditText platformView, IEnumerable<string> keywords) internal static void HighlightSyntax(MauiAppCompatEditText platformView, IEnumerable<string> keywords)
@@ -191,6 +302,15 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
// ── Inner helpers ───────────────────────────────────────────────────── // ── Inner helpers ─────────────────────────────────────────────────────
private sealed class SimpleTextWatcher : Java.Lang.Object, ITextWatcher
{
private readonly Action _callback;
public SimpleTextWatcher(Action callback) { _callback = callback; }
public void BeforeTextChanged(Java.Lang.ICharSequence s, int start, int count, int after) { }
public void OnTextChanged(Java.Lang.ICharSequence s, int start, int before, int count) { }
public void AfterTextChanged(IEditable s) { _callback(); }
}
private sealed class CypherTextWatcher : Java.Lang.Object, ITextWatcher private sealed class CypherTextWatcher : Java.Lang.Object, ITextWatcher
{ {
private readonly MauiAppCompatEditText _view; private readonly MauiAppCompatEditText _view;
@@ -210,9 +330,50 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
{ {
if (_updating) return; if (_updating) return;
_updating = true; _updating = true;
try { HighlightSyntax(_view, _keywords); } try
{
AutoCapitalizeKeywords(_view, _keywords);
HighlightSyntax(_view, _keywords);
}
finally { _updating = false; } finally { _updating = false; }
} }
} }
internal static void AutoCapitalizeKeywords(MauiAppCompatEditText platformView, IEnumerable<string> keywords)
{
if (!Microsoft.Maui.Storage.Preferences.Default.Get("auto_capitalize", true)) return;
var text = platformView.Text ?? string.Empty;
var selStart = platformView.SelectionStart;
var selEnd = platformView.SelectionEnd;
var changed = false;
var chars = text.ToCharArray();
foreach (var word in keywords)
{
var regex = new Regex("\\b" + Regex.Escape(word) + "\\b", RegexOptions.IgnoreCase);
foreach (Match match in regex.Matches(text))
{
// Skip if already uppercase
var segment = text.Substring(match.Index, match.Length);
if (segment == word) continue;
// Don't capitalize if cursor is right at the end of this word (user still typing)
if (selStart == match.Index + match.Length) continue;
for (var i = 0; i < match.Length; i++)
chars[match.Index + i] = word[i];
changed = true;
}
}
if (changed)
{
var newText = new string(chars);
platformView.SetText(newText, TextView.BufferType.Editable);
if (selStart >= 0 && selStart <= newText.Length)
platformView.SetSelection(Math.Min(selStart, newText.Length), Math.Min(selEnd, newText.Length));
}
}
} }
} }

View File

@@ -38,7 +38,9 @@ namespace Xamarin.Neo4j.Android
var statusBarColor = isDark var statusBarColor = isDark
? AColor.ParseColor("#0c0c0c") ? AColor.ParseColor("#0c0c0c")
: AColor.ParseColor("#f5f5f5"); : AColor.ParseColor("#f5f5f5");
#pragma warning disable CA1422
Window.SetStatusBarColor(statusBarColor); Window.SetStatusBarColor(statusBarColor);
#pragma warning restore CA1422
var controller = new WindowInsetsControllerCompat(Window, Window.DecorView); var controller = new WindowInsetsControllerCompat(Window, Window.DecorView);
controller.AppearanceLightStatusBars = !isDark; controller.AppearanceLightStatusBars = !isDark;
@@ -53,21 +55,24 @@ namespace Xamarin.Neo4j.Android
{ {
base.OnWindowFocusChanged(hasFocus); base.OnWindowFocusChanged(hasFocus);
if (hasFocus) if (hasFocus)
TintToolbarOverflow(Window?.DecorView as ViewGroup); TintToolbarIcons(Window?.DecorView as ViewGroup);
} }
// MAUI creates its toolbar programmatically so theme-based tinting doesn't reach // MAUI creates its toolbar programmatically so theme-based tinting doesn't reach
// the overflow icon. Walk the view tree and apply the tint directly. // the overflow icon. Walk the view tree and apply the tint directly.
private static void TintToolbarOverflow(ViewGroup? parent) private static void TintToolbarIcons(ViewGroup parent)
{ {
if (parent == null) return; if (parent == null) return;
for (var i = 0; i < parent.ChildCount; i++) for (var i = 0; i < parent.ChildCount; i++)
{ {
var child = parent.GetChildAt(i); var child = parent.GetChildAt(i);
if (child is Toolbar toolbar) if (child is Toolbar toolbar)
{
toolbar.OverflowIcon?.SetTint(AColor.White); toolbar.OverflowIcon?.SetTint(AColor.White);
toolbar.NavigationIcon?.SetTint(AColor.White);
}
else if (child is ViewGroup vg) else if (child is ViewGroup vg)
TintToolbarOverflow(vg); TintToolbarIcons(vg);
} }
} }
} }

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="1" android:versionCode="2"
android:versionName="1.0" android:versionName="2.0.0"
package="nl.resoftware.pocketgraph"> package="nl.resoftware.pocketgraph">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="36" /> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="36" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -9,6 +9,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Net.Security; using System.Net.Security;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Android.Runtime;
using Java.Security; using Java.Security;
using Java.Security.Cert; using Java.Security.Cert;
using Javax.Net.Ssl; using Javax.Net.Ssl;
@@ -25,8 +26,9 @@ namespace Xamarin.Neo4j
var factory = TrustManagerFactory.GetInstance(TrustManagerFactory.DefaultAlgorithm); var factory = TrustManagerFactory.GetInstance(TrustManagerFactory.DefaultAlgorithm);
factory.Init((KeyStore)null); factory.Init((KeyStore)null);
_androidTrustManager = factory.GetTrustManagers() _androidTrustManager = factory.GetTrustManagers()
.OfType<IX509TrustManager>() .OfType<Java.Lang.Object>()
.First(); .Select(m => m.JavaCast<IX509TrustManager>())
.First(m => m != null);
} }
public override bool ValidateServerCertificate(Uri uri, X509Certificate2 certificate, X509Chain chain, public override bool ValidateServerCertificate(Uri uri, X509Certificate2 certificate, X509Chain chain,

View File

@@ -22,11 +22,11 @@ namespace Xamarin.Neo4j.Android.Services
{ {
var context = global::Android.App.Application.Context; var context = global::Android.App.Application.Context;
var info = context.PackageManager.GetPackageInfo(context.PackageName, 0); var info = context.PackageManager.GetPackageInfo(context.PackageName, 0);
#pragma warning disable CS0618 #pragma warning disable CS0618, CA1416, CA1422
return Build.VERSION.SdkInt >= BuildVersionCodes.P return Build.VERSION.SdkInt >= BuildVersionCodes.P
? info.LongVersionCode.ToString() ? info.LongVersionCode.ToString()
: info.VersionCode.ToString(); : info.VersionCode.ToString();
#pragma warning restore CS0618 #pragma warning restore CS0618, CA1416, CA1422
} }
} }
} }

View File

@@ -12,6 +12,8 @@
<Nullable>disable</Nullable> <Nullable>disable</Nullable>
<!-- Embed all assemblies in the APK so plain adb install works without Fast Deployment --> <!-- Embed all assemblies in the APK so plain adb install works without Fast Deployment -->
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
<ApplicationDisplayVersion>2.0.0</ApplicationDisplayVersion>
<ApplicationVersion>2</ApplicationVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -32,6 +34,10 @@
Link="Assets\logo.png" /> Link="Assets\logo.png" />
<AndroidAsset Include="..\Xamarin.Neo4j.iOS\Resources\resoftware.png" <AndroidAsset Include="..\Xamarin.Neo4j.iOS\Resources\resoftware.png"
Link="Assets\resoftware.png" /> Link="Assets\resoftware.png" />
<MauiImage Include="..\Xamarin.Neo4j.iOS\Resources\resoftware.png"
Link="Resources\Images\resoftware.png" />
<MauiImage Include="..\Xamarin.Neo4j.iOS\Resources\logo.png"
Link="Resources\Images\logo.png" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -47,4 +53,56 @@
<ProjectReference Include="..\Xamarin.Neo4j\Xamarin.Neo4j.csproj" /> <ProjectReference Include="..\Xamarin.Neo4j\Xamarin.Neo4j.csproj" />
</ItemGroup> </ItemGroup>
<!-- Android Release: shrink and obfuscate the Java/DEX half of the app with
R8. .NET for Android runs no DEX shrinker by default, so Play Console's
"DEX code optimization" report scores obfuscation at ~1%. The trimmer emits
a keep rule for every Java type the managed bindings reference
(obj/.../proguard/proguard_project_references.cfg), aapt2 one for every
class named in a layout, and each Android Callable Wrapper gets its own,
so only library-internal classes are renamed. -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<AndroidLinkTool>r8</AndroidLinkTool>
</PropertyGroup>
<ItemGroup>
<ProguardConfiguration Include="proguard.cfg" />
</ItemGroup>
<!-- Swap the SDK's generated proguard_xamarin.cfg (which hardcodes
-dontobfuscate, a flag nothing later in the config list can undo) for our
copy without it, and regenerate the -printmapping/-keepattributes tail the
SDK appends to that same file so crash stacks stay retraceable. -->
<Target Name="_AndroidObfuscateDex" AfterTargets="_CalculateProguardConfigurationFiles" Condition="'$(AndroidLinkTool)' == 'r8'">
<PropertyGroup>
<_AndroidObfuscationProguardConfig>$(IntermediateOutputPath)proguard\proguard_obfuscate.cfg</_AndroidObfuscationProguardConfig>
<_AndroidAaptProguardRules>$(IntermediateOutputPath)proguard\aapt_rules.cached.txt</_AndroidAaptProguardRules>
</PropertyGroup>
<ItemGroup>
<_AndroidObfuscationProguardLines Include="-ignorewarnings" Condition="'$(AndroidR8IgnoreWarnings)' == 'True'" />
<_AndroidObfuscationProguardLines Include="-keepattributes SourceFile" />
<_AndroidObfuscationProguardLines Include="-keepattributes LineNumberTable" />
<!-- Absolute: R8 resolves a relative -printmapping against the directory
of the config file that declares it, not the working directory. -->
<_AndroidObfuscationProguardLines Include="-printmapping &quot;$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(AndroidProguardMappingFile)'))&quot;" Condition="'$(AndroidProguardMappingFile)' != ''" />
</ItemGroup>
<MakeDir Directories="$(IntermediateOutputPath)proguard" />
<WriteLinesToFile File="$(_AndroidObfuscationProguardConfig)" Lines="@(_AndroidObfuscationProguardLines)" Overwrite="true" WriteOnlyWhenDifferent="true" />
<!-- aapt2 derives keep rules for every class named in a layout or the
manifest, but the SDK adds them to @(ProguardConfiguration) inside
_CreateBaseApkWithAapt2 and registers the file as a FileWrite, so an
incremental Release build that skips the resource link both loses the
item and has IncrementalClean delete the file, shipping an APK that
dies inflating androidx.appcompat.widget.FitWindowsFrameLayout. Keep a
copy outside FileWrites and feed R8 that one instead. -->
<Copy SourceFiles="$(IntermediateOutputPath)aapt_rules.txt" DestinationFiles="$(_AndroidAaptProguardRules)" SkipUnchangedFiles="true" Condition="Exists('$(IntermediateOutputPath)aapt_rules.txt')" />
<Error Condition="!Exists('$(_AndroidAaptProguardRules)')" Text="R8 is enabled but no aapt2 keep rules are available: delete obj\$(Configuration) and publish again to force a full resource link." />
<ItemGroup>
<_ProguardConfiguration Remove="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg" />
<_ProguardConfiguration Remove="$(IntermediateOutputPath)aapt_rules.txt" />
<_ProguardConfiguration Include="$(MSBuildProjectDirectory)\proguard_xamarin.cfg" />
<_ProguardConfiguration Include="$(_AndroidObfuscationProguardConfig)" />
<_ProguardConfiguration Include="$(_AndroidAaptProguardRules)" />
</ItemGroup>
</Target>
</Project> </Project>

View File

@@ -0,0 +1,22 @@
# App-level R8 keep rules, on top of the ones .NET for Android generates
# (see proguard_xamarin.cfg and the _AndroidObfuscateDex target in the csproj).
# The .NET runtime resolves this one by name from native code -- no DEX
# reference points at it, so R8 renames it and startup dies with
# "ClassNotFoundException: net.dot.android.ApplicationRegistration".
# The SDK's own config only covers net.dot.jni.** and net.dot.android.crypto.**,
# because upstream never obfuscates.
-keep class net.dot.android.** { *; <init>(...); }
# The trimmer's generated keep rules (proguard_project_references.cfg) cover the
# Java *methods* the bindings call, but not their fields -- upstream never
# obfuscates, so the gap never showed. A bound property backed by a Java field
# reads it through JNI by name, and renaming breaks that:
# NoSuchFieldError: no "Landroidx/lifecycle/Lifecycle$State;" field "DESTROYED"
# NoSuchFieldError: no "I" field "left" in class "Landroidx/core/graphics/Insets;"
# Only the API surface a binding can reach needs it; private fields still get
# renamed, and class names -- what Play's report counts -- are untouched by this.
-keepclassmembers class * {
public <fields>;
protected <fields>;
}

View File

@@ -0,0 +1,45 @@
# Verbatim copy of the ProGuard configuration .NET for Android generates at
# obj/<Config>/<TFM>/proguard/proguard_xamarin.cfg, minus its hardcoded
# "-dontobfuscate". ProGuard has no switch that undoes that flag, so the only
# way to let R8 rename DEX classes is to drop the file that sets it from R8's
# --pg-conf list and substitute this one (see the _AndroidObfuscateDex target
# in the csproj).
#
# Taken from Microsoft.Android.Sdk 36.1.69 (.NET 10). When the Android SDK pack
# is updated, diff this against the generated file after a Release build --
# a keep rule added upstream and missed here means a runtime JNI failure.
# The trailing -ignorewarnings/-keepattributes/-printmapping lines the SDK
# appends to that file are regenerated by the csproj target instead.
-keep class android.support.multidex.MultiDexApplication { <init>(); }
-keep class net.dot.jni.** { *; <init>(); }
-keep class mono.MonoRuntimeProvider* { *; <init>(...); }
-keep class mono.MonoPackageManager { *; <init>(...); }
-keep class mono.MonoPackageManager_Resources { *; <init>(...); }
-keep class mono.android.** { *; <init>(...); }
-keep class mono.java.** { *; <init>(...); }
-keep class mono.javax.** { *; <init>(...); }
-keep class net.dot.jni.ManagedPeer { *; <init>(...); }
-keep class xamarin.android.net.ServerCertificateCustomValidator_TrustManager { *; <init>(...); }
-keep class xamarin.android.net.ServerCertificateCustomValidator_TrustManager_FakeSSLSession { *; <init>(...); }
-keep class xamarin.android.net.ServerCertificateCustomValidator_AlwaysAcceptingHostnameVerifier { *; <init>(...); }
-keep class android.runtime.** { <init>(...); }
-keep class assembly_mono_android.android.runtime.** { <init>(...); }
# hash for android.runtime and assembly_mono_android.android.runtime.
-keep class md52ce486a14f4bcd95899665e9d932190b.** { *; <init>(...); }
-keepclassmembers class md52ce486a14f4bcd95899665e9d932190b.** { *; <init>(...); }
# .NET runtime
-keep class net.dot.android.crypto.** { *; <init>(...); }
# Android's template misses fluent setters...
-keepclassmembers class * extends android.view.View {
*** set*(...);
}
# also misses those inflated custom layout stuff from xml...
-keepclassmembers class * extends android.view.View {
<init>(android.content.Context,android.util.AttributeSet);
<init>(android.content.Context,android.util.AttributeSet,int);
}

View File

@@ -12,6 +12,7 @@ using System.Collections.Generic;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using CoreGraphics; using CoreGraphics;
using Foundation; using Foundation;
using Microsoft.Maui;
using Microsoft.Maui.Controls; using Microsoft.Maui.Controls;
using Microsoft.Maui.Handlers; using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform; using Microsoft.Maui.Platform;
@@ -22,6 +23,15 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
{ {
public class QueryEditorHandler : EditorHandler public class QueryEditorHandler : EditorHandler
{ {
public QueryEditorHandler()
{
Mapper.AppendToMapping(nameof(Editor.Text), (handler, _) =>
{
if (handler is QueryEditorHandler qeh && handler.PlatformView is MauiTextView textView)
HighlightWords(textView, qeh._keyWords);
});
}
private readonly string[] _keyWords = private readonly string[] _keyWords =
[ [
// Clauses // Clauses
@@ -60,14 +70,16 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
const float accessoryHeight = 58f; const float accessoryHeight = 58f;
const float buttonWidth = 33f; const float buttonWidth = 33f;
const float executeButtonWidth = 90f;
const float padding = 8f; const float padding = 8f;
const float pillHeight = 38f; const float pillHeight = 38f;
var keys = new[] var keys = new[]
{ {
("(", "("), (")", ")"), ("[", "["), ("]", "]"), ("(", "("), (")", ")"), ("[", "["), ("]", "]"),
(":", ":"), ("-", "-"), ("\u2192", "->"), ("\u2190", "<-") ("{", "{"), ("}", "}"), (":", ":"), ("-", "-"),
("\u2192", "->"), ("\u2190", "<-"), ("\"", "\""),
("'", "'"), (".", "."), ("=", "="), ("*", "*"), ("$", "$")
}; };
// Outer accessory — clear so system background shows through // Outer accessory — clear so system background shows through
@@ -112,50 +124,42 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
scrollView.BottomAnchor.ConstraintEqualTo(pillContainer.BottomAnchor), 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(pillContainer);
accessoryView.AddSubview(executeBtn);
NSLayoutConstraint.ActivateConstraints(new[] 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.LeadingAnchor.ConstraintEqualTo(accessoryView.LeadingAnchor, padding),
pillContainer.TrailingAnchor.ConstraintEqualTo(executeBtn.LeadingAnchor, -padding), pillContainer.TrailingAnchor.ConstraintEqualTo(accessoryView.TrailingAnchor, -padding),
pillContainer.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor), pillContainer.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor),
pillContainer.HeightAnchor.ConstraintEqualTo(pillHeight), pillContainer.HeightAnchor.ConstraintEqualTo(pillHeight),
}); });
platformView.InputAccessoryView = accessoryView; platformView.InputAccessoryView = accessoryView;
platformView.Changed += (s, e) => HighlightWords(platformView, _keyWords); // Ensure accessory hides when keyboard is dismissed by external gesture/scroll
var keyboardHideObserver = UIKeyboard.Notifications.ObserveWillHide((s, args) =>
{
// The InputAccessoryView is removed with the keyboard automatically.
// But if the editor remains first responder after an external dismiss
// (e.g. interactive dismiss on scroll), resign to fully hide the bar.
if (platformView.IsFirstResponder)
platformView.ResignFirstResponder();
});
HighlightWords(platformView, _keyWords); platformView.Changed += (s, e) =>
{
AutoCapitalizeKeywords(platformView, _keyWords);
HighlightWords(platformView, _keyWords);
};
} }
private static void HighlightWords(UITextView platformView, IEnumerable<string> wordsToHighlight) private static void HighlightWords(UITextView platformView, IEnumerable<string> wordsToHighlight)
{ {
var text = PreprocessText(platformView.Text ?? string.Empty); var text = PreprocessText(platformView.Text ?? string.Empty);
var attributedText = new NSMutableAttributedString(text); var attributedText = new NSMutableAttributedString(text);
attributedText.AddAttribute(UIStringAttributeKey.ForegroundColor,
UIColor.Label,
new NSRange(0, text.Length));
foreach (var word in wordsToHighlight) foreach (var word in wordsToHighlight)
{ {
@@ -192,6 +196,37 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
} }
} }
private static void AutoCapitalizeKeywords(UITextView platformView, IEnumerable<string> keywords)
{
if (!Microsoft.Maui.Storage.Preferences.Default.Get("auto_capitalize", true)) return;
var text = platformView.Text ?? string.Empty;
var cursorPos = platformView.SelectedRange;
var changed = false;
var chars = text.ToCharArray();
foreach (var word in keywords)
{
var regex = new Regex("\\b" + Regex.Escape(word) + "\\b", RegexOptions.IgnoreCase);
foreach (Match match in regex.Matches(text))
{
var segment = text.Substring(match.Index, match.Length);
if (segment == word) continue;
if ((nint)cursorPos.Location == match.Index + match.Length) continue;
for (var i = 0; i < match.Length; i++)
chars[match.Index + i] = word[i];
changed = true;
}
}
if (changed)
{
platformView.Text = new string(chars);
platformView.SelectedRange = cursorPos;
}
}
private static string PreprocessText(string text) private static string PreprocessText(string text)
{ {
text = text.Replace("\u2018", "'"); text = text.Replace("\u2018", "'");

View File

@@ -24,7 +24,7 @@
</imageView> </imageView>
</subviews> </subviews>
<viewLayoutGuide key="safeArea" id="1038"/> <viewLayoutGuide key="safeArea" id="1038"/>
<color key="backgroundColor" red="0.19136750699999999" green="0.2006954849" blue="0.2327556908" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="0.047058823529" green="0.047058823529" blue="0.047058823529" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstItem="198" firstAttribute="centerX" secondItem="1038" secondAttribute="centerX" id="201"/> <constraint firstItem="198" firstAttribute="centerX" secondItem="1038" secondAttribute="centerX" id="201"/>
<constraint firstItem="1038" firstAttribute="centerY" secondItem="198" secondAttribute="centerY" id="203"/> <constraint firstItem="1038" firstAttribute="centerY" secondItem="198" secondAttribute="centerY" id="203"/>

View File

@@ -21,7 +21,9 @@ namespace Xamarin.Neo4j
SetTheme(Current.RequestedTheme); SetTheme(Current.RequestedTheme);
RequestedThemeChanged += (s, e) => SetTheme(e.RequestedTheme); RequestedThemeChanged += (s, e) => SetTheme(e.RequestedTheme);
#pragma warning disable CS0618
MainPage = new NavigationPage(new ConnectionsPage()); MainPage = new NavigationPage(new ConnectionsPage());
#pragma warning restore CS0618
// SetTheme ran before MainPage was assigned, so apply bar colours now. // SetTheme ran before MainPage was assigned, so apply bar colours now.
ApplyNavBarColors(); ApplyNavBarColors();
@@ -47,9 +49,13 @@ namespace Xamarin.Neo4j
// theme changes while the app is backgrounded are picked up on return. // theme changes while the app is backgrounded are picked up on return.
public void ApplyNavBarColors() public void ApplyNavBarColors()
{ {
if (MainPage is NavigationPage navPage) if (Windows.Count > 0 && Windows[0].Page is NavigationPage navPage)
{ {
navPage.BarBackgroundColor = Color.FromArgb("#31333b"); if (Resources.TryGetValue("NavigationBarColor", out var navColor) && navColor is Color color)
navPage.BarBackgroundColor = color;
else
navPage.BarBackgroundColor = Color.FromArgb("#31333b");
navPage.BarTextColor = Colors.White; navPage.BarTextColor = Colors.White;
} }
} }

View File

@@ -3,6 +3,8 @@
<ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j" xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
x:DataType="models:Neo4jConnectionString"
x:Class="Xamarin.Neo4j.Controls.ConnectionCell"> x:Class="Xamarin.Neo4j.Controls.ConnectionCell">
<Grid Padding="15, 10"> <Grid Padding="15, 10">
<StackLayout Spacing="2"> <StackLayout Spacing="2">

View File

@@ -10,6 +10,7 @@ using Microsoft.Maui.Controls.Xaml;
namespace Xamarin.Neo4j.Controls namespace Xamarin.Neo4j.Controls
{ {
[XamlCompilation(XamlCompilationOptions.Compile)] [XamlCompilation(XamlCompilationOptions.Compile)]
#pragma warning disable CS0618
public partial class ConnectionCell : ViewCell public partial class ConnectionCell : ViewCell
{ {
public ConnectionCell() public ConnectionCell()

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui" 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"
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
x:DataType="models:License"
x:Class="Xamarin.Neo4j.Controls.LicenseCell">
<StackLayout Padding="15, 15, 15, 0"> <StackLayout Padding="15, 15, 15, 0">
<Label FontAttributes="Bold" TextColor="{DynamicResource Accent}" Text="{Binding Name}"> <Label FontAttributes="Bold" TextColor="{DynamicResource Accent}" Text="{Binding Name}">
<Label.GestureRecognizers> <Label.GestureRecognizers>

View File

@@ -10,6 +10,7 @@ using Microsoft.Maui.Controls.Xaml;
namespace Xamarin.Neo4j.Controls namespace Xamarin.Neo4j.Controls
{ {
[XamlCompilation(XamlCompilationOptions.Compile)] [XamlCompilation(XamlCompilationOptions.Compile)]
#pragma warning disable CS0618
public partial class LicenseCell : ViewCell public partial class LicenseCell : ViewCell
{ {
public LicenseCell() public LicenseCell()

View File

@@ -2,12 +2,15 @@
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
x:DataType="models:QueryResult"
x:Class="Xamarin.Neo4j.Controls.QueryResultView" x:Class="Xamarin.Neo4j.Controls.QueryResultView"
x:Name="self"> x:Name="self">
<Grid VerticalOptions="Start"> <Grid VerticalOptions="Start">
<!-- Graph WebView --> <!-- Graph WebView -->
<WebView x:Name="graphView" <WebView x:Name="graphView" x:DataType="{x:Null}"
HorizontalOptions="FillAndExpand" HorizontalOptions="FillAndExpand"
IsVisible="{Binding CanDisplayGraph}" IsVisible="{Binding CanDisplayGraph}"
HeightRequest="{Binding GraphViewHeight, Source={x:Reference self}}" /> HeightRequest="{Binding GraphViewHeight, Source={x:Reference self}}" />

View File

@@ -4,6 +4,7 @@ using System.Reflection;
using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Controls; using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Xaml; using Microsoft.Maui.Controls.Xaml;
using Microsoft.Maui.Storage;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui; using Microsoft.Maui;
using Xamarin.Neo4j.Managers; using Xamarin.Neo4j.Managers;
@@ -42,8 +43,28 @@ namespace Xamarin.Neo4j.Controls
graphView.Source = new HtmlWebViewSource { Html = QueryResult?.NeovisHtml }; graphView.Source = new HtmlWebViewSource { Html = QueryResult?.NeovisHtml };
} }
private double _originalHeight;
private async void OnGraphViewNavigating(object sender, WebNavigatingEventArgs e) private async void OnGraphViewNavigating(object sender, WebNavigatingEventArgs e)
{ {
// Panel open/close — expand WebView so panel has room
if (e.Url.Contains("panelopen"))
{
e.Cancel = true;
_originalHeight = graphView.HeightRequest;
var screenService = IPlatformApplication.Current.Services.GetRequiredService<Services.Interfaces.IScreenSizeService>();
var screenHeight = screenService.GetScreenHeight();
graphView.HeightRequest = Math.Max(_originalHeight, screenHeight * 0.7);
return;
}
if (e.Url.Contains("panelclose"))
{
e.Cancel = true;
if (_originalHeight > 0)
graphView.HeightRequest = _originalHeight;
return;
}
if (!e.Url.Contains("expand") || !e.Url.Contains("nodeId")) return; if (!e.Url.Contains("expand") || !e.Url.Contains("nodeId")) return;
e.Cancel = true; e.Cancel = true;
@@ -110,6 +131,13 @@ namespace Xamarin.Neo4j.Controls
html = html.Replace("{{edges}}", edgesJson); html = html.Replace("{{edges}}", edgesJson);
html = html.Replace("{{backgroundColor}}", isDark ? "#292C31" : "#FFFFFF"); html = html.Replace("{{backgroundColor}}", isDark ? "#292C31" : "#FFFFFF");
html = html.Replace("{{textColor}}", isDark ? "#FFFFFF" : "#000000"); html = html.Replace("{{textColor}}", isDark ? "#FFFFFF" : "#000000");
html = html.Replace("{{panelBg}}", isDark ? "#141414" : "#ffffff");
html = html.Replace("{{borderColor}}", isDark ? "#2a2a2a" : "#e0e0e0");
html = html.Replace("{{mutedColor}}", isDark ? "#868686" : "#868a92");
html = html.Replace("{{accentColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
html = html.Replace("{{primaryColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
html = html.Replace("{{errorColor}}", isDark ? "#ff6b6b" : "#c62828");
html = html.Replace("{{autoZoom}}", Preferences.Default.Get("auto_zoom", true) ? "true" : "false");
QueryResult.NeovisHtml = html; QueryResult.NeovisHtml = html;
} }

View File

@@ -666,7 +666,7 @@ namespace Xamarin.Neo4j.Fonts
public const string Divide = "\uf529"; public const string Divide = "\uf529";
public const string DoorClosed = "\uf52a"; public const string DoorClosed = "\uf52a";
public const string DoorOpen = "\uf52b"; public const string DoorOpen = "\uf52b";
public const string Equals = "\uf52c"; public new const string Equals = "\uf52c";
public const string Feather = "\uf52d"; public const string Feather = "\uf52d";
public const string Frog = "\uf52e"; public const string Frog = "\uf52e";
public const string GasPump = "\uf52f"; public const string GasPump = "\uf52f";

View File

@@ -69,6 +69,7 @@ namespace Xamarin.Neo4j.Managers
if (connectionStringToUpdate == null) if (connectionStringToUpdate == null)
return; return;
connectionStringToUpdate.Name = connectionString.Name;
connectionStringToUpdate.Scheme = connectionString.Scheme; connectionStringToUpdate.Scheme = connectionString.Scheme;
connectionStringToUpdate.Host = connectionString.Host; connectionStringToUpdate.Host = connectionString.Host;
connectionStringToUpdate.Username = connectionString.Username; connectionStringToUpdate.Username = connectionString.Username;

View File

@@ -18,5 +18,7 @@ namespace Xamarin.Neo4j.Models
public bool Default { get; set; } public bool Default { get; set; }
public string DisplayName => Name + (Default ? " 🏠" : string.Empty); public string DisplayName => Name + (Default ? " 🏠" : string.Empty);
public override string ToString() => DisplayName;
} }
} }

View File

@@ -27,6 +27,12 @@ namespace Xamarin.Neo4j.Models
public string Password { get; set; } public string Password { get; set; }
public bool IsSecure => Scheme?.Contains("+s") ?? false;
public string SchemeShortName => Scheme != null && Scheme.StartsWith("bolt") ? "bolt" : "neo4j";
public string DisplayName => string.IsNullOrWhiteSpace(Name) ? Host : Name;
public Tuple<string, bool, bool> ParseHost() public Tuple<string, bool, bool> ParseHost()
{ {
var fullHost = Scheme + Host; var fullHost = Scheme + Host;

View File

@@ -3,13 +3,20 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j" xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
x:DataType="vm:AddConnectionViewModel"
Title="Add Connection" Title="Add Connection"
x:Class="Xamarin.Neo4j.Pages.AddConnectionPage"> x:Class="Xamarin.Neo4j.Pages.AddConnectionPage">
<ContentPage.Content> <ContentPage.Content>
<ScrollView> <ScrollView>
<StackLayout Padding="24" Spacing="12"> <StackLayout Padding="24" Spacing="12">
<StackLayout Spacing="4"> <StackLayout Spacing="4">
<Label Text="Scheme:" FontAttributes="Bold" FontSize="Small" /> <Label Text="Name" FontAttributes="Bold" FontSize="Small" />
<Entry Text="{Binding Name}" Placeholder="My Database" />
</StackLayout>
<StackLayout Spacing="4">
<Label Text="Scheme" FontAttributes="Bold" FontSize="Small" />
<Picker SelectedItem="{Binding Scheme}"> <Picker SelectedItem="{Binding Scheme}">
<Picker.ItemsSource> <Picker.ItemsSource>
<x:Array Type="{x:Type x:String}"> <x:Array Type="{x:Type x:String}">
@@ -25,17 +32,17 @@
</StackLayout> </StackLayout>
<StackLayout Spacing="4"> <StackLayout Spacing="4">
<Label Text="Host:" FontAttributes="Bold" FontSize="Small" /> <Label Text="Host" FontAttributes="Bold" FontSize="Small" />
<Entry Text="{Binding Host}" /> <Entry Text="{Binding Host}" />
</StackLayout> </StackLayout>
<StackLayout Spacing="4"> <StackLayout Spacing="4">
<Label Text="Username:" FontAttributes="Bold" FontSize="Small" /> <Label Text="Username" FontAttributes="Bold" FontSize="Small" />
<Entry Text="{Binding Username}" Keyboard="Default" /> <Entry Text="{Binding Username}" Keyboard="Default" />
</StackLayout> </StackLayout>
<StackLayout Spacing="4"> <StackLayout Spacing="4">
<Label Text="Password:" FontAttributes="Bold" FontSize="Small" /> <Label Text="Password" FontAttributes="Bold" FontSize="Small" />
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<Entry Grid.Column="0" x:Name="passwordEntry" <Entry Grid.Column="0" x:Name="passwordEntry"
Text="{Binding Password}" Text="{Binding Password}"
@@ -51,7 +58,7 @@
</Grid> </Grid>
</StackLayout> </StackLayout>
<Button Text="Test" BackgroundColor="Transparent" TextColor="{DynamicResource SecondaryTextColor}" BorderColor="{DynamicResource SecondaryTextColor}" BorderWidth="1" Command="{Binding Commands[Test]}" /> <Button Text="Test" BackgroundColor="Transparent" TextColor="{DynamicResource PrimaryTextColor}" BorderColor="{DynamicResource PrimaryTextColor}" BorderWidth="1" Command="{Binding Commands[Test]}" />
<Button Text="Connect" BackgroundColor="Transparent" TextColor="{DynamicResource PrimaryTextColor}" BorderColor="{DynamicResource PrimaryTextColor}" BorderWidth="1" Command="{Binding Commands[Connect]}" /> <Button Text="Connect" BackgroundColor="Transparent" TextColor="{DynamicResource PrimaryTextColor}" BorderColor="{DynamicResource PrimaryTextColor}" BorderWidth="1" Command="{Binding Commands[Connect]}" />
<Button Text="Save" BackgroundColor="{DynamicResource Accent}" TextColor="{DynamicResource QueryTextBackground}" Command="{Binding Commands[Save]}" /> <Button Text="Save" BackgroundColor="{DynamicResource Accent}" TextColor="{DynamicResource QueryTextBackground}" Command="{Binding Commands[Save]}" />
</StackLayout> </StackLayout>

View File

@@ -3,10 +3,26 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j" xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j" xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
x:Class="Xamarin.Neo4j.Pages.ConnectionsPage" xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
x:DataType="vm:ConnectionsViewModel"
x:Class="Xamarin.Neo4j.Pages.ConnectionsPage"
x:Name="connectionsPage" x:Name="connectionsPage"
Title="PocketGraph"> Title=" ">
<NavigationPage.TitleView>
<StackLayout Orientation="Horizontal" Spacing="8" VerticalOptions="Center" HorizontalOptions="Start" Padding="4,0,0,0">
<Label Text="{x:Static fonts:FontAwesomeSolid.ProjectDiagram}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="18"
TextColor="White"
VerticalOptions="Center" />
<Label Text="PocketGraph"
FontSize="18" FontAttributes="Bold"
TextColor="White"
VerticalOptions="Center" />
</StackLayout>
</NavigationPage.TitleView>
<ContentPage.ToolbarItems> <ContentPage.ToolbarItems>
<ToolbarItem Text="Settings" Order="Secondary" Command="{Binding Commands[OpenSettings]}" /> <ToolbarItem Text="Settings" Order="Secondary" Command="{Binding Commands[OpenSettings]}" />
@@ -14,47 +30,126 @@ x:Class="Xamarin.Neo4j.Pages.ConnectionsPage"
<ContentPage.Content> <ContentPage.Content>
<Grid> <Grid>
<ListView ItemsSource="{Binding ConnectionStrings}" HasUnevenRows="True" SelectionMode="None" ItemTapped="OpenSession" IsVisible="{Binding HasItems}">
<ListView.ItemTemplate>
<DataTemplate>
<controls:ConnectionCell>
<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"> <!-- Scrollable connection cards -->
<Label Text="{x:Static fonts:FontAwesomeSolid.Server}" <ScrollView IsVisible="{Binding HasItems}">
FontFamily="{StaticResource FontAwesomeSolid}" <StackLayout BindableLayout.ItemsSource="{Binding ConnectionStrings}"
FontSize="48" Spacing="0" Padding="0,16,0,80">
TextColor="{DynamicResource SecondaryTextColor}" <BindableLayout.ItemTemplate>
HorizontalOptions="Center" /> <DataTemplate x:DataType="models:Neo4jConnectionString">
<Label Text="No connections" <Border Margin="16,0,16,8"
FontSize="18" BackgroundColor="{DynamicResource Extreme}"
FontAttributes="Bold" Stroke="{DynamicResource QueryTextBorder}"
TextColor="{DynamicResource SecondaryTextColor}" StrokeThickness="1"
HorizontalOptions="Center" /> StrokeShape="RoundRectangle 10">
<Label Text="Tap + to add a connection" <Grid ColumnDefinitions="Auto,*,Auto" Padding="14,12">
FontSize="14" <Grid.GestureRecognizers>
TextColor="{DynamicResource SecondaryTextColor}" <TapGestureRecognizer x:DataType="{x:Null}"
HorizontalOptions="Center" /> Command="{Binding BindingContext.Commands[OpenConnection], Source={x:Reference connectionsPage}}"
</StackLayout> CommandParameter="{Binding .}" />
</Grid.GestureRecognizers>
<!-- FAB --> <!-- Database icon -->
<Button Text="{x:Static fonts:FontAwesomeSolid.Plus}" <Border Grid.Column="0"
FontFamily="{StaticResource FontAwesomeSolid}" WidthRequest="40" HeightRequest="40"
FontSize="20" StrokeThickness="0"
WidthRequest="56" HeightRequest="56" StrokeShape="RoundRectangle 20"
CornerRadius="28" BackgroundColor="{DynamicResource QueryActionBar}"
BackgroundColor="{DynamicResource Accent}" VerticalOptions="Center">
TextColor="White" <Label Text="{x:Static fonts:FontAwesomeSolid.Database}"
VerticalOptions="End" HorizontalOptions="End" FontFamily="{StaticResource FontAwesomeSolid}"
Margin="0,0,16,16" FontSize="16"
Command="{Binding Commands[AddConnection]}" /> TextColor="{DynamicResource SecondaryTextColor}"
HorizontalOptions="Center"
VerticalOptions="Center" />
</Border>
<!-- Name + Host + scheme badge -->
<StackLayout Grid.Column="1" Spacing="2" Padding="12,0,8,0" VerticalOptions="Center">
<Label Text="{Binding DisplayName}"
FontSize="15" FontAttributes="Bold"
TextColor="{DynamicResource PrimaryTextColor}"
LineBreakMode="TailTruncation" MaxLines="1" />
<Label Text="{Binding Host}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
LineBreakMode="TailTruncation" MaxLines="1" />
<StackLayout Orientation="Horizontal" Spacing="4">
<Label IsVisible="{Binding IsSecure}"
Text="{x:Static fonts:FontAwesomeSolid.Lock}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="9"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
<Border BackgroundColor="{DynamicResource QueryActionBar}"
StrokeThickness="0"
StrokeShape="RoundRectangle 6"
Padding="6,2">
<Label Text="{Binding SchemeShortName}"
FontSize="9" FontAttributes="Bold"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Border>
</StackLayout>
</StackLayout>
<!-- Edit / Delete -->
<StackLayout Grid.Column="2" Orientation="Horizontal" Spacing="0" VerticalOptions="Center">
<Button x:DataType="{x:Null}" Text="{x:Static fonts:FontAwesomeSolid.PencilAlt}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="14"
WidthRequest="38" HeightRequest="38"
BackgroundColor="Transparent"
TextColor="{DynamicResource SecondaryTextColor}"
Padding="0"
Command="{Binding BindingContext.Commands[EditConnectionString], Source={x:Reference connectionsPage}}"
CommandParameter="{Binding .}" />
<Button x:DataType="{x:Null}" Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="14"
WidthRequest="38" HeightRequest="38"
BackgroundColor="Transparent"
TextColor="{DynamicResource SecondaryTextColor}"
Padding="0"
Command="{Binding BindingContext.Commands[DeleteConnectionString], Source={x:Reference connectionsPage}}"
CommandParameter="{Binding .}" />
</StackLayout>
</Grid>
</Border>
</DataTemplate>
</BindableLayout.ItemTemplate>
</StackLayout>
</ScrollView>
<!-- Empty state -->
<StackLayout IsVisible="{Binding IsEmpty}" VerticalOptions="Center" HorizontalOptions="Center" Spacing="12">
<Label Text="{x:Static fonts:FontAwesomeSolid.Server}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="48"
TextColor="{DynamicResource SecondaryTextColor}"
HorizontalOptions="Center" />
<Label Text="No connections"
FontSize="18"
FontAttributes="Bold"
TextColor="{DynamicResource SecondaryTextColor}"
HorizontalOptions="Center" />
<Label Text="Tap + to add a connection"
FontSize="14"
TextColor="{DynamicResource SecondaryTextColor}"
HorizontalOptions="Center" />
</StackLayout>
<!-- FAB -->
<Button Text="{x:Static fonts:FontAwesomeSolid.Plus}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="20"
WidthRequest="56" HeightRequest="56"
CornerRadius="28"
BackgroundColor="{DynamicResource FabBackgroundColor}"
TextColor="{DynamicResource FabTextColor}"
VerticalOptions="End" HorizontalOptions="End"
Margin="0,0,16,16"
Command="{Binding Commands[AddConnection]}" />
</Grid> </Grid>
</ContentPage.Content> </ContentPage.Content>
</ContentPage> </ContentPage>

View File

@@ -42,9 +42,7 @@ namespace Xamarin.Neo4j.Pages
ViewModel.LoadConnectionStrings(); ViewModel.LoadConnectionStrings();
} }
private void OpenSession(object sender, ItemTappedEventArgs e)
{
ViewModel.OpenSession((Neo4jConnectionString)e.Item);
}
} }
} }

View File

@@ -2,6 +2,8 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
x:DataType="vm:GraphViewModel"
Title="Graph" Title="Graph"
x:Class="Xamarin.Neo4j.Pages.GraphPage"> x:Class="Xamarin.Neo4j.Pages.GraphPage">
<ContentPage.Content> <ContentPage.Content>

View File

@@ -3,6 +3,8 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls" xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls"
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
x:DataType="vm:LicensesViewModel"
Title="Software Licenses" Title="Software Licenses"
x:Class="Xamarin.Neo4j.Pages.LicensesPage"> x:Class="Xamarin.Neo4j.Pages.LicensesPage">
<ContentPage.Content> <ContentPage.Content>

View File

@@ -3,6 +3,9 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j" xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
x:DataType="vm:QueriesViewModel"
Title="Queries" Title="Queries"
x:Name="queriesPage" x:Name="queriesPage"
x:Class="Xamarin.Neo4j.Pages.QueriesPage"> x:Class="Xamarin.Neo4j.Pages.QueriesPage">
@@ -17,10 +20,10 @@
<Grid> <Grid>
<ListView ItemsSource="{Binding Queries}" ItemTapped="StartSessionWithQuery" IsVisible="{Binding HasItems}"> <ListView ItemsSource="{Binding Queries}" ItemTapped="StartSessionWithQuery" IsVisible="{Binding HasItems}">
<ListView.ItemTemplate> <ListView.ItemTemplate>
<DataTemplate> <DataTemplate x:DataType="models:Query">
<TextCell Text="{Binding Name}" Detail="{Binding QueryText}"> <TextCell Text="{Binding Name}" Detail="{Binding QueryText}">
<TextCell.ContextActions> <TextCell.ContextActions>
<MenuItem Text="Delete" Command="{Binding BindingContext.Commands[DeleteQuery], Source={x:Reference queriesPage} }" CommandParameter="{Binding .}" IsDestructive="true" /> <MenuItem x:DataType="{x:Null}" Text="Delete" Command="{Binding BindingContext.Commands[DeleteQuery], Source={x:Reference queriesPage} }" CommandParameter="{Binding .}" IsDestructive="true" />
</TextCell.ContextActions> </TextCell.ContextActions>
</TextCell> </TextCell>
</DataTemplate> </DataTemplate>

View File

@@ -4,6 +4,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j" xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j" xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
x:DataType="vm:SessionViewModel"
x:Class="Xamarin.Neo4j.Pages.SessionPage" x:Class="Xamarin.Neo4j.Pages.SessionPage"
x:Name="sessionPage"> x:Name="sessionPage">
<NavigationPage.TitleView> <NavigationPage.TitleView>
@@ -21,7 +24,7 @@
</Label.FormattedText> </Label.FormattedText>
</Label> </Label>
<Picker x:Name="databasePicker" ItemsSource="{Binding AvailableDatabases}" ItemDisplayBinding="{Binding DisplayName}" SelectedItem="{Binding CurrentDatabase}" IsVisible="False" /> <Picker x:Name="databasePicker" ItemsSource="{Binding AvailableDatabases}" SelectedItem="{Binding CurrentDatabase}" IsVisible="False" />
</StackLayout> </StackLayout>
</NavigationPage.TitleView> </NavigationPage.TitleView>
@@ -30,7 +33,8 @@
<ToolbarItem.IconImageSource> <ToolbarItem.IconImageSource>
<FontImageSource <FontImageSource
FontFamily="{StaticResource FontAwesomeSolid}" FontFamily="{StaticResource FontAwesomeSolid}"
Glyph="{x:Static fonts:FontAwesomeSolid.Play}" Size="20" /> Glyph="{x:Static fonts:FontAwesomeSolid.Play}" Size="20"
Color="#007AFF" />
</ToolbarItem.IconImageSource> </ToolbarItem.IconImageSource>
</ToolbarItem> </ToolbarItem>
</ContentPage.ToolbarItems> </ContentPage.ToolbarItems>
@@ -81,11 +85,11 @@
IsVisible="{Binding HasSavedQueries}" IsVisible="{Binding HasSavedQueries}"
Spacing="0"> Spacing="0">
<BindableLayout.ItemTemplate> <BindableLayout.ItemTemplate>
<DataTemplate> <DataTemplate x:DataType="models:Query">
<Grid ColumnDefinitions="*,Auto" Padding="0,10"> <Grid ColumnDefinitions="*,Auto" Padding="0,10">
<StackLayout Grid.Column="0" Spacing="2"> <StackLayout Grid.Column="0" Spacing="2">
<StackLayout.GestureRecognizers> <StackLayout.GestureRecognizers>
<TapGestureRecognizer <TapGestureRecognizer x:DataType="{x:Null}"
Command="{Binding BindingContext.Commands[LoadQuery], Source={x:Reference sessionPage}}" Command="{Binding BindingContext.Commands[LoadQuery], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" /> CommandParameter="{Binding .}" />
</StackLayout.GestureRecognizers> </StackLayout.GestureRecognizers>
@@ -96,7 +100,7 @@
MaxLines="1" MaxLines="1"
LineBreakMode="TailTruncation" /> LineBreakMode="TailTruncation" />
</StackLayout> </StackLayout>
<Button Grid.Column="1" <Button Grid.Column="1" x:DataType="{x:Null}"
Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}" Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
FontFamily="{StaticResource FontAwesomeSolid}" FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="16" FontSize="16"
@@ -143,74 +147,74 @@
<StackLayout BindableLayout.ItemsSource="{Binding QueryResults}" Spacing="0"> <StackLayout BindableLayout.ItemsSource="{Binding QueryResults}" Spacing="0">
<BindableLayout.ItemTemplate> <BindableLayout.ItemTemplate>
<DataTemplate> <DataTemplate x:DataType="models:QueryResult">
<Border Margin="12,0,12,8" <Border Margin="12,0,12,6"
BackgroundColor="{DynamicResource Extreme}" BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}" Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1" StrokeThickness="1"
StrokeShape="RoundRectangle 4"> StrokeShape="RoundRectangle 4">
<StackLayout Spacing="0"> <StackLayout Spacing="0">
<!-- Action row --> <!-- Action row -->
<Grid ColumnDefinitions="*,*,*,*,Auto" Padding="4,2"> <Grid ColumnDefinitions="*,*,*,*,Auto" Padding="4,0" x:DataType="{x:Null}">
<Button Grid.Column="0" <Button Grid.Column="0"
Text="{x:Static fonts:FontAwesomeSolid.Star}" Text="{x:Static fonts:FontAwesomeSolid.Star}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15" FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
BackgroundColor="Transparent" BackgroundColor="Transparent"
TextColor="{DynamicResource SecondaryTextColor}" TextColor="{DynamicResource SecondaryTextColor}"
HeightRequest="40" HeightRequest="32"
Command="{Binding BindingContext.Commands[SaveQuery], Source={x:Reference sessionPage}}" Command="{Binding BindingContext.Commands[SaveQuery], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" /> CommandParameter="{Binding .}" />
<Button Grid.Column="1" <Button Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.Expand}" Text="{x:Static fonts:FontAwesomeSolid.Expand}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15" FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
BackgroundColor="Transparent" BackgroundColor="Transparent"
IsEnabled="{Binding CanDisplayGraph}" IsEnabled="{Binding CanDisplayGraph}"
TextColor="{DynamicResource SecondaryTextColor}" TextColor="{DynamicResource SecondaryTextColor}"
HeightRequest="40" HeightRequest="32"
Command="{Binding BindingContext.Commands[OpenGraph], Source={x:Reference sessionPage}}" Command="{Binding BindingContext.Commands[OpenGraph], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" /> CommandParameter="{Binding .}" />
<Button Grid.Column="2" <Button Grid.Column="2"
Text="{x:Static fonts:FontAwesomeSolid.Code}" Text="{x:Static fonts:FontAwesomeSolid.Code}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15" FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
BackgroundColor="Transparent" BackgroundColor="Transparent"
IsEnabled="{Binding Success}" IsEnabled="{Binding Success}"
TextColor="{DynamicResource SecondaryTextColor}" TextColor="{DynamicResource SecondaryTextColor}"
HeightRequest="40" HeightRequest="32"
Command="{Binding BindingContext.Commands[OpenTable], Source={x:Reference sessionPage}}" Command="{Binding BindingContext.Commands[OpenTable], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" /> CommandParameter="{Binding .}" />
<Button Grid.Column="3" <Button Grid.Column="3"
Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}" Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15" FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
BackgroundColor="Transparent" BackgroundColor="Transparent"
TextColor="{DynamicResource SecondaryTextColor}" TextColor="{DynamicResource SecondaryTextColor}"
HeightRequest="40" HeightRequest="32"
Command="{Binding BindingContext.Commands[DeleteResult], Source={x:Reference sessionPage}}" Command="{Binding BindingContext.Commands[DeleteResult], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" /> CommandParameter="{Binding .}" />
<Button Grid.Column="4" <Button Grid.Column="4"
Text="{x:Static fonts:FontAwesomeSolid.ChevronUp}" Text="{x:Static fonts:FontAwesomeSolid.ChevronUp}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="12" FontFamily="{StaticResource FontAwesomeSolid}" FontSize="10"
BackgroundColor="Transparent" BackgroundColor="Transparent"
TextColor="{DynamicResource SecondaryTextColor}" TextColor="{DynamicResource SecondaryTextColor}"
WidthRequest="40" HeightRequest="40" WidthRequest="32" HeightRequest="32"
Clicked="ToggleResultCollapse" /> Clicked="ToggleResultCollapse" />
</Grid> </Grid>
<!-- Query label --> <!-- Query label -->
<Label Text="{Binding DisplayQuery}" <Label Text="{Binding DisplayQuery}"
Padding="12,0,12,8" Padding="12,0,12,4"
FontSize="12" FontSize="11"
TextColor="{DynamicResource SecondaryTextColor}" TextColor="{DynamicResource SecondaryTextColor}"
LineBreakMode="TailTruncation" LineBreakMode="TailTruncation"
MaxLines="2"> MaxLines="1">
<Label.GestureRecognizers> <Label.GestureRecognizers>
<TapGestureRecognizer <TapGestureRecognizer x:DataType="{x:Null}"
Command="{Binding BindingContext.Commands[LoadResultQuery], Source={x:Reference sessionPage}}" Command="{Binding BindingContext.Commands[LoadResultQuery], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" /> CommandParameter="{Binding .}" />
</Label.GestureRecognizers> </Label.GestureRecognizers>
</Label> </Label>
<!-- Result content --> <!-- Result content -->
<controls:QueryResultView <controls:QueryResultView x:DataType="{x:Null}"
GraphViewHeight="{Binding BindingContext.GraphViewHeight, Source={x:Reference sessionPage}}" /> GraphViewHeight="{Binding BindingContext.GraphViewHeight, Source={x:Reference sessionPage}}" />
</StackLayout> </StackLayout>
</Border> </Border>
@@ -219,27 +223,28 @@
</StackLayout> </StackLayout>
</StackLayout> </StackLayout>
<!-- Empty hint (no results yet) -->
<StackLayout IsVisible="{Binding IsEmpty}" HorizontalOptions="Center" Spacing="12" Padding="32,24">
<Label Text="{x:Static fonts:FontAwesomeSolid.Database}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="48"
TextColor="{DynamicResource SecondaryTextColor}"
HorizontalOptions="Center" />
<Label Text="No results yet"
FontSize="18" FontAttributes="Bold"
TextColor="{DynamicResource SecondaryTextColor}"
HorizontalOptions="Center" />
<Label Text="Write a query and tap play, or load a saved query above"
FontSize="14"
TextColor="{DynamicResource SecondaryTextColor}"
HorizontalTextAlignment="Center"
HorizontalOptions="Center" />
</StackLayout>
</StackLayout> </StackLayout>
</ScrollView> </ScrollView>
<!-- Empty hint (no results yet) — overlaid and vertically centered -->
<StackLayout Grid.Row="1" IsVisible="{Binding IsEmpty}" HorizontalOptions="Center" VerticalOptions="Center" Spacing="12" Padding="32,24">
<Label Text="{x:Static fonts:FontAwesomeSolid.Database}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="48"
TextColor="{DynamicResource SecondaryTextColor}"
HorizontalOptions="Center" />
<Label Text="No results yet"
FontSize="18" FontAttributes="Bold"
TextColor="{DynamicResource SecondaryTextColor}"
HorizontalOptions="Center" />
<Label Text="Write a query and tap play, or load a saved query above"
FontSize="14"
TextColor="{DynamicResource SecondaryTextColor}"
HorizontalTextAlignment="Center"
HorizontalOptions="Center" />
</StackLayout>
</Grid> </Grid>
</ContentPage.Content> </ContentPage.Content>
</ContentPage> </ContentPage>

View File

@@ -3,6 +3,8 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j" xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
x:DataType="vm:SettingsViewModel"
Title="Settings" Title="Settings"
x:Class="Xamarin.Neo4j.Pages.SettingsPage"> x:Class="Xamarin.Neo4j.Pages.SettingsPage">
<ContentPage.IconImageSource> <ContentPage.IconImageSource>
@@ -13,157 +15,188 @@
</ContentPage.IconImageSource> </ContentPage.IconImageSource>
<ContentPage.Content> <ContentPage.Content>
<Grid RowDefinitions="*,Auto"> <ScrollView>
<StackLayout Padding="16" Spacing="24">
<ScrollView Grid.Row="0">
<StackLayout Padding="16" Spacing="24">
<!-- Support -->
<StackLayout Spacing="8">
<Label Text="SUPPORT"
FontSize="11" FontAttributes="Bold"
CharacterSpacing="2"
TextColor="{DynamicResource SecondaryTextColor}"
Margin="4,0,0,0" />
<Border BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1"
StrokeShape="RoundRectangle 4">
<StackLayout Spacing="0">
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[ContactSupport]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Contact Support"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[RateApp]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Rate PocketGraph"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
</StackLayout>
</Border>
</StackLayout>
<!-- Data -->
<StackLayout Spacing="8">
<Label Text="DATA"
FontSize="11" FontAttributes="Bold"
CharacterSpacing="2"
TextColor="{DynamicResource SecondaryTextColor}"
Margin="4,0,0,0" />
<Border BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1"
StrokeShape="RoundRectangle 4">
<StackLayout Spacing="0">
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[ClearConnections]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Clear Saved Connections"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[ClearQueries]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Clear Saved Queries"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
</StackLayout>
</Border>
</StackLayout>
<!-- About -->
<StackLayout Spacing="8">
<Label Text="ABOUT"
FontSize="11" FontAttributes="Bold"
CharacterSpacing="2"
TextColor="{DynamicResource SecondaryTextColor}"
Margin="4,0,0,0" />
<Border BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1"
StrokeShape="RoundRectangle 4">
<StackLayout Spacing="0">
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Label Grid.Column="0" Text="{Binding VersionLabel}"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[OpenLicensesPage]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Software Licenses"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
</StackLayout>
</Border>
</StackLayout>
<!-- Editor -->
<StackLayout Spacing="8">
<Label Text="EDITOR"
FontSize="11" FontAttributes="Bold"
CharacterSpacing="2"
TextColor="{DynamicResource SecondaryTextColor}"
Margin="4,0,0,0" />
<Border BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1"
StrokeShape="RoundRectangle 4">
<StackLayout Spacing="0">
<Grid ColumnDefinitions="*,Auto" Padding="16,10">
<Label Grid.Column="0" Text="Auto-capitalize keywords"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Switch Grid.Column="1" OnColor="#007AFF"
IsToggled="{Binding AutoCapitalize}"
VerticalOptions="Center" />
</Grid>
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
<Grid ColumnDefinitions="*,Auto" Padding="16,10">
<Label Grid.Column="0" Text="Auto-zoom on node tap"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Switch Grid.Column="1" OnColor="#007AFF"
IsToggled="{Binding AutoZoom}"
VerticalOptions="Center" />
</Grid>
</StackLayout>
</Border>
</StackLayout>
<!-- Data -->
<StackLayout Spacing="8">
<Label Text="DATA"
FontSize="11" FontAttributes="Bold"
CharacterSpacing="2"
TextColor="{DynamicResource SecondaryTextColor}"
Margin="4,0,0,0" />
<Border BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1"
StrokeShape="RoundRectangle 4">
<StackLayout Spacing="0">
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[ClearConnections]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Clear Saved Connections"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[ClearQueries]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Clear Saved Queries"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
</StackLayout>
</Border>
</StackLayout>
<!-- Support -->
<StackLayout Spacing="8">
<Label Text="SUPPORT"
FontSize="11" FontAttributes="Bold"
CharacterSpacing="2"
TextColor="{DynamicResource SecondaryTextColor}"
Margin="4,0,0,0" />
<Border BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1"
StrokeShape="RoundRectangle 4">
<StackLayout Spacing="0">
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[ContactSupport]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Contact Support"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[RateApp]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Rate PocketGraph"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
</StackLayout>
</Border>
</StackLayout>
<!-- About -->
<StackLayout Spacing="8">
<Label Text="ABOUT"
FontSize="11" FontAttributes="Bold"
CharacterSpacing="2"
TextColor="{DynamicResource SecondaryTextColor}"
Margin="4,0,0,0" />
<Border BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1"
StrokeShape="RoundRectangle 4">
<StackLayout Spacing="0">
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Label Grid.Column="0" Text="{Binding VersionLabel}"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[OpenLicensesPage]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Software Licenses"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
VerticalOptions="Center" />
</Grid>
</StackLayout>
</Border>
</StackLayout>
<!-- Footer -->
<StackLayout Spacing="15" Padding="9,16,9,32">
<Label Text="A product of" HorizontalOptions="Center" FontAttributes="Italic" FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}" />
<Border HorizontalOptions="Center"
BackgroundColor="#ffffff"
StrokeThickness="0"
StrokeShape="RoundRectangle 12"
Padding="16,8">
<Border.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[OpenReSoftwareSite]}" />
</Border.GestureRecognizers>
<Image Source="resoftware.png" WidthRequest="90" />
</Border>
</StackLayout> </StackLayout>
</ScrollView>
<StackLayout Grid.Row="1" Spacing="4" Padding="25,16">
<Label HorizontalOptions="Center" FontSize="12">
<Label.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[OpenReSoftwareSite]}" />
</Label.GestureRecognizers>
<Label.FormattedText>
<FormattedString>
<Span Text="A product of " TextColor="{DynamicResource SecondaryTextColor}" FontAttributes="Italic" />
<Span Text="Re: Software" TextColor="{DynamicResource Accent}" FontAttributes="Bold" />
</FormattedString>
</Label.FormattedText>
</Label>
</StackLayout> </StackLayout>
</ScrollView>
</Grid>
</ContentPage.Content> </ContentPage.Content>
</ContentPage> </ContentPage>

View File

@@ -76,12 +76,18 @@ namespace Xamarin.Neo4j.Pages
var isDark = Application.Current.RequestedTheme == AppTheme.Dark; var isDark = Application.Current.RequestedTheme == AppTheme.Dark;
html = html.Replace("{{json}}", json); html = html.Replace("{{json}}", json);
html = html.Replace("{{backgroundColor}}", isDark ? "#1e1e1e" : "#ffffff"); html = html.Replace("{{backgroundColor}}", isDark ? "#0c0c0c" : "#f5f5f5");
html = html.Replace("{{textColor}}", isDark ? "#d4d4d4" : "#1a1a1a"); html = html.Replace("{{textColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
html = html.Replace("{{toolbarBg}}", isDark ? "#252526" : "#f5f5f5"); html = html.Replace("{{toolbarBg}}", isDark ? "#141414" : "#ffffff");
html = html.Replace("{{inputBg}}", isDark ? "#3c3c3c" : "#ffffff"); html = html.Replace("{{inputBg}}", isDark ? "#1a1a1a" : "#ffffff");
html = html.Replace("{{borderColor}}", isDark ? "#454545" : "#cccccc"); html = html.Replace("{{borderColor}}", isDark ? "#2a2a2a" : "#e0e0e0");
html = html.Replace("{{mutedColor}}", isDark ? "#808080" : "#8a8a8a"); html = html.Replace("{{mutedColor}}", isDark ? "#868686" : "#868a92");
html = html.Replace("{{stringColor}}", isDark ? "#8BC34A" : "#2E7D32");
html = html.Replace("{{numberColor}}", isDark ? "#64B5F6" : "#1565C0");
html = html.Replace("{{boolColor}}", isDark ? "#FFB74D" : "#E65100");
html = html.Replace("{{nullColor}}", isDark ? "#868686" : "#868a92");
html = html.Replace("{{keyColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
html = html.Replace("{{errorColor}}", isDark ? "#ff6b6b" : "#c62828");
return html; return html;
} }
} }

View File

@@ -17,6 +17,9 @@
<Color x:Key="QueryTextBorder">#2a2a2a</Color> <Color x:Key="QueryTextBorder">#2a2a2a</Color>
<Color x:Key="QueryActionBar">#141414</Color> <Color x:Key="QueryActionBar">#141414</Color>
<Color x:Key="Extreme">#141414</Color> <Color x:Key="Extreme">#141414</Color>
<Color x:Key="NavigationBarColor">#141414</Color>
<Color x:Key="FabBackgroundColor">#ffffff</Color>
<Color x:Key="FabTextColor">#0c0c0c</Color>
<Style TargetType="ContentPage" ApplyToDerivedTypes="True"> <Style TargetType="ContentPage" ApplyToDerivedTypes="True">
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" /> <Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
@@ -28,7 +31,7 @@
</Style> </Style>
<Style TargetType="NavigationPage"> <Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="#141414"/> <Setter Property="BarBackgroundColor" Value="{StaticResource NavigationBarColor}"/>
<Setter Property="BarTextColor" Value="#e2e2e2" /> <Setter Property="BarTextColor" Value="#e2e2e2" />
</Style> </Style>

View File

@@ -17,6 +17,9 @@
<Color x:Key="QueryTextBorder">#e0e0e0</Color> <Color x:Key="QueryTextBorder">#e0e0e0</Color>
<Color x:Key="QueryActionBar">#f5f5f5</Color> <Color x:Key="QueryActionBar">#f5f5f5</Color>
<Color x:Key="Extreme">#ffffff</Color> <Color x:Key="Extreme">#ffffff</Color>
<Color x:Key="NavigationBarColor">#0c0c0c</Color>
<Color x:Key="FabBackgroundColor">#0c0c0c</Color>
<Color x:Key="FabTextColor">#ffffff</Color>
<Style TargetType="ContentPage" ApplyToDerivedTypes="True"> <Style TargetType="ContentPage" ApplyToDerivedTypes="True">
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" /> <Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
@@ -37,7 +40,7 @@
</Style> </Style>
<Style TargetType="NavigationPage"> <Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="#0c0c0c"/> <Setter Property="BarBackgroundColor" Value="{StaticResource NavigationBarColor}"/>
<Setter Property="BarTextColor" Value="#e2e2e2" /> <Setter Property="BarTextColor" Value="#e2e2e2" />
</Style> </Style>

View File

@@ -26,7 +26,7 @@ namespace Xamarin.Neo4j.ViewModels
{ {
private Guid? _id; private Guid? _id;
private string _scheme, _host, _username, _password; private string _name, _scheme, _host, _username, _password;
private readonly Neo4jService _neo4jService; private readonly Neo4jService _neo4jService;
@@ -48,27 +48,20 @@ namespace Xamarin.Neo4j.ViewModels
var (_, message) = await _neo4jService.EstablishConnection(connectionString); var (_, message) = await _neo4jService.EstablishConnection(connectionString);
await Application.Current.MainPage.DisplayAlert("", message, "OK"); await Application.Current.Windows[0].Page.DisplayAlertAsync("", message, "OK");
})); }));
Commands.Add("Save", new Command(async () => Commands.Add("Save", new Command(async () =>
{ {
var connectionString = BuildConnectionString(); var connectionString = BuildConnectionString();
if (string.IsNullOrWhiteSpace(connectionString.Name))
connectionString.Name = connectionString.Host;
if (_id.HasValue) if (_id.HasValue)
await ConnectionStringManager.UpdateConnectionString(_id.Value, connectionString); await ConnectionStringManager.UpdateConnectionString(_id.Value, connectionString);
else else
{
var name = await Application.Current.MainPage.DisplayPromptAsync("Save Connection", "How do you want to name this connection?", "Save", "Cancel");
if (string.IsNullOrWhiteSpace(name))
return;
connectionString.Name = name;
await ConnectionStringManager.AddConnectionString(connectionString); await ConnectionStringManager.AddConnectionString(connectionString);
}
await Navigation.PopAsync(); await Navigation.PopAsync();
})); }));
@@ -83,7 +76,7 @@ namespace Xamarin.Neo4j.ViewModels
await Navigation.PushAsync(new SessionPage(connectionString)); await Navigation.PushAsync(new SessionPage(connectionString));
else else
await Application.Current.MainPage.DisplayAlert("", message, "OK"); await Application.Current.Windows[0].Page.DisplayAlertAsync("", message, "OK");
})); }));
} }
@@ -91,6 +84,7 @@ namespace Xamarin.Neo4j.ViewModels
{ {
_id = neo4JConnectionString.Id; _id = neo4JConnectionString.Id;
Name = neo4JConnectionString.Name;
Scheme = neo4JConnectionString.Scheme; Scheme = neo4JConnectionString.Scheme;
Host = neo4JConnectionString.Host; Host = neo4JConnectionString.Host;
Username = neo4JConnectionString.Username; Username = neo4JConnectionString.Username;
@@ -114,6 +108,7 @@ namespace Xamarin.Neo4j.ViewModels
return new Neo4jConnectionString return new Neo4jConnectionString
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = Name,
Scheme = Scheme, Scheme = Scheme,
Host = Host, Host = Host,
Username = Username, Username = Username,
@@ -123,6 +118,16 @@ namespace Xamarin.Neo4j.ViewModels
#region Bindable Properties #region Bindable Properties
public string Name
{
get => _name;
set
{
_name = value;
OnPropertyChanged(nameof(Name));
}
}
public string Scheme public string Scheme
{ {
get => _scheme; get => _scheme;

View File

@@ -50,6 +50,12 @@ namespace Xamarin.Neo4j.ViewModels
LoadConnectionStrings(); LoadConnectionStrings();
})); }));
Commands.Add("OpenConnection", new Command((o) =>
{
if (o is Neo4jConnectionString cs)
OpenSession(cs);
}));
Commands.Add("OpenSettings", new Command(async () => Commands.Add("OpenSettings", new Command(async () =>
{ {
await Navigation.PushAsync(new SettingsPage()); await Navigation.PushAsync(new SettingsPage());
@@ -87,6 +93,7 @@ namespace Xamarin.Neo4j.ViewModels
OnPropertyChanged(nameof(ConnectionStrings)); OnPropertyChanged(nameof(ConnectionStrings));
OnPropertyChanged(nameof(IsEmpty)); OnPropertyChanged(nameof(IsEmpty));
OnPropertyChanged(nameof(HasItems)); OnPropertyChanged(nameof(HasItems));
OnPropertyChanged(nameof(ConnectionCountLabel));
} }
} }
@@ -94,6 +101,20 @@ namespace Xamarin.Neo4j.ViewModels
public bool HasItems => !IsEmpty; public bool HasItems => !IsEmpty;
public string ConnectionCountLabel
{
get
{
var count = _connectionStrings?.Count() ?? 0;
return count switch
{
0 => "No connections yet",
1 => "1 connection",
_ => $"{count} connections"
};
}
}
#endregion #endregion
} }
} }

View File

@@ -42,7 +42,7 @@ namespace Xamarin.Neo4j.ViewModels
{ {
if (ConnectionStringManager.ActiveConnectionString == null) if (ConnectionStringManager.ActiveConnectionString == null)
{ {
await Application.Current.MainPage.DisplayAlert("", "Please select a connection before starting a session.", "OK"); await Application.Current.Windows[0].Page.DisplayAlertAsync("", "Please select a connection before starting a session.", "OK");
return; return;
} }

View File

@@ -82,7 +82,7 @@ namespace Xamarin.Neo4j.ViewModels
if (!(o is Query query)) if (!(o is Query query))
return; return;
var confirmed = await Application.Current.MainPage.DisplayAlert( var confirmed = await Application.Current.Windows[0].Page.DisplayAlertAsync(
"Delete Query", "Delete Query",
$"Delete \"{query.Name}\"?", $"Delete \"{query.Name}\"?",
"Delete", "Cancel"); "Delete", "Cancel");
@@ -115,7 +115,7 @@ namespace Xamarin.Neo4j.ViewModels
Commands.Add("SaveQuery", new Command(async (o) => Commands.Add("SaveQuery", new Command(async (o) =>
{ {
if (!(o is QueryResult result)) return; if (!(o is QueryResult result)) return;
var name = await Application.Current.MainPage.DisplayPromptAsync( var name = await Application.Current.Windows[0].Page.DisplayPromptAsync(
"Save Query", "What would you like to call this query?"); "Save Query", "What would you like to call this query?");
if (!string.IsNullOrWhiteSpace(name)) if (!string.IsNullOrWhiteSpace(name))
{ {
@@ -163,7 +163,7 @@ namespace Xamarin.Neo4j.ViewModels
if (!isConnected) if (!isConnected)
{ {
await Application.Current.MainPage.DisplayAlert("", message, "OK"); await Application.Current.Windows[0].Page.DisplayAlertAsync("", message, "OK");
return; return;
} }
@@ -276,7 +276,7 @@ namespace Xamarin.Neo4j.ViewModels
get get
{ {
var screenHeight = _screenSizeService.GetScreenHeight(); var screenHeight = _screenSizeService.GetScreenHeight();
return Math.Max(200, screenHeight - 300); return Math.Max(120, (screenHeight - 300) / 2);
} }
} }

View File

@@ -77,7 +77,7 @@ namespace Xamarin.Neo4j.ViewModels
if (!Email.Default.IsComposeSupported) if (!Email.Default.IsComposeSupported)
{ {
await Application.Current.MainPage.DisplayAlert( await Application.Current.Windows[0].Page.DisplayAlertAsync(
"No Email App", "No email client is configured on this device.", "OK"); "No Email App", "No email client is configured on this device.", "OK");
return; return;
} }
@@ -96,7 +96,7 @@ namespace Xamarin.Neo4j.ViewModels
Commands.Add("ClearConnections", new Command(async () => Commands.Add("ClearConnections", new Command(async () =>
{ {
var clear = await Application.Current.MainPage.DisplayAlert( var clear = await Application.Current.Windows[0].Page.DisplayAlertAsync(
"Clear Connections", "Clear Connections",
"Are you sure you want to remove all saved connections?", "Are you sure you want to remove all saved connections?",
"Clear", "Cancel"); "Clear", "Cancel");
@@ -109,7 +109,7 @@ namespace Xamarin.Neo4j.ViewModels
Commands.Add("ClearQueries", new Command(async () => Commands.Add("ClearQueries", new Command(async () =>
{ {
var clear = await Application.Current.MainPage.DisplayAlert( var clear = await Application.Current.Windows[0].Page.DisplayAlertAsync(
"Clear Saved Queries", "Clear Saved Queries",
"Are you sure you want to remove all saved queries?", "Are you sure you want to remove all saved queries?",
"Clear", "Cancel"); "Clear", "Cancel");
@@ -141,6 +141,26 @@ namespace Xamarin.Neo4j.ViewModels
} }
} }
public bool AutoCapitalize
{
get => Preferences.Default.Get("auto_capitalize", true);
set
{
Preferences.Default.Set("auto_capitalize", value);
OnPropertyChanged(nameof(AutoCapitalize));
}
}
public bool AutoZoom
{
get => Preferences.Default.Get("auto_zoom", true);
set
{
Preferences.Default.Set("auto_zoom", value);
OnPropertyChanged(nameof(AutoZoom));
}
}
#endregion #endregion
} }
} }

View File

@@ -3,9 +3,9 @@
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style> <style>
:root{--bg:{{backgroundColor}};--fg:{{textColor}};--toolbar:{{toolbarBg}};--input:{{inputBg}};--border:{{borderColor}};--muted:{{mutedColor}}} :root{--bg:{{backgroundColor}};--fg:{{textColor}};--toolbar:{{toolbarBg}};--input:{{inputBg}};--border:{{borderColor}};--muted:{{mutedColor}};--str:{{stringColor}};--num:{{numberColor}};--bool:{{boolColor}};--nul:{{nullColor}};--key:{{keyColor}};--err:{{errorColor}}}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent} *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:'Courier New',monospace;font-size:13px;background:var(--bg);color:var(--fg);overscroll-behavior:none} body{font-family:'Courier New',monospace;font-size:14px;line-height:1.5;background:var(--bg);color:var(--fg);overscroll-behavior:none}
#toolbar{position:sticky;top:0;z-index:10;background:var(--toolbar);padding:8px;display:flex;flex-direction:column;gap:6px;border-bottom:1px solid var(--border)} #toolbar{position:sticky;top:0;z-index:10;background:var(--toolbar);padding:8px;display:flex;flex-direction:column;gap:6px;border-bottom:1px solid var(--border)}
#toolbar input{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--input);color:var(--fg);font-family:inherit;font-size:13px;outline:none;-webkit-appearance:none} #toolbar input{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--input);color:var(--fg);font-family:inherit;font-size:13px;outline:none;-webkit-appearance:none}
#toolbar-row2{display:flex;gap:6px;align-items:center} #toolbar-row2{display:flex;gap:6px;align-items:center}
@@ -14,8 +14,8 @@ body{font-family:'Courier New',monospace;font-size:13px;background:var(--bg);col
.tbtn:active{opacity:.7} .tbtn:active{opacity:.7}
#status{font-size:11px;color:var(--muted);padding:0 2px;min-height:15px} #status{font-size:11px;color:var(--muted);padding:0 2px;min-height:15px}
#output{padding:10px} #output{padding:10px}
.s{color:#4CAF50}.n{color:#64B5F6}.b{color:#FFB74D}.nl{color:#90A4AE}.k{color:#EF9A9A}.e{color:#EF5350} .s{color:var(--str)}.n{color:var(--num)}.b{color:var(--bool)}.nl{color:var(--nul)}.k{color:var(--key);font-weight:600}.e{color:var(--err)}
.br{color:var(--fg);opacity:.7}.cm{color:var(--fg);opacity:.4} .br{color:var(--fg);opacity:.85}.cm{color:var(--fg);opacity:.5}
/* Block-based tree nodes */ /* Block-based tree nodes */
.node,.knode{display:block} .node,.knode{display:block}
.nhd{display:block;cursor:default} .nhd{display:block;cursor:default}
@@ -154,13 +154,19 @@ function count(v){
function setTheme(isDark){ function setTheme(isDark){
const r=document.documentElement.style; const r=document.documentElement.style;
r.setProperty('--bg',isDark?'#1e1e1e':'#ffffff'); r.setProperty('--bg',isDark?'#0c0c0c':'#f5f5f5');
r.setProperty('--fg',isDark?'#d4d4d4':'#1a1a1a'); r.setProperty('--fg',isDark?'#e2e2e2':'#0c0c0c');
r.setProperty('--toolbar',isDark?'#252526':'#f5f5f5'); r.setProperty('--toolbar',isDark?'#141414':'#ffffff');
r.setProperty('--input',isDark?'#3c3c3c':'#ffffff'); r.setProperty('--input',isDark?'#1a1a1a':'#ffffff');
r.setProperty('--border',isDark?'#454545':'#cccccc'); r.setProperty('--border',isDark?'#2a2a2a':'#e0e0e0');
r.setProperty('--muted',isDark?'#808080':'#8a8a8a'); r.setProperty('--muted',isDark?'#868686':'#868a92');
document.body.style.background=isDark?'#1e1e1e':'#ffffff'; r.setProperty('--str',isDark?'#8BC34A':'#2E7D32');
r.setProperty('--num',isDark?'#64B5F6':'#1565C0');
r.setProperty('--bool',isDark?'#FFB74D':'#E65100');
r.setProperty('--nul',isDark?'#868686':'#868a92');
r.setProperty('--key',isDark?'#e2e2e2':'#0c0c0c');
r.setProperty('--err',isDark?'#ff6b6b':'#c62828');
document.body.style.background=isDark?'#0c0c0c':'#f5f5f5';
} }
document.getElementById('srch').addEventListener('input',function(){srch=this.value.trim().toLowerCase();render()}); document.getElementById('srch').addEventListener('input',function(){srch=this.value.trim().toLowerCase();render()});

View File

@@ -2,42 +2,281 @@
<html lang="en-US"> <html lang="en-US">
<head> <head>
<title>Graph</title> <title>Graph</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: {{backgroundColor}}; } html, body { width: 100%; height: 100%; overflow: hidden; background: {{backgroundColor}}; }
canvas { display: block; position: absolute; top: 0; left: 0; } canvas { display: block; position: absolute; top: 0; left: 0; }
#popup { /* ── Slide-up detail panel ── */
#panel-backdrop {
display: none; display: none;
position: fixed; position: fixed;
bottom: 24px; top: 0;
left: 16px; left: 0;
right: 16px; right: 0;
background: rgba(20, 20, 30, 0.95); bottom: 55%;
color: #fff; z-index: 90;
pointer-events: none;
}
#panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: {{panelBg}};
border-top: 1px solid {{borderColor}};
border-radius: 16px 16px 0 0;
height: 55%;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1), height 0.3s cubic-bezier(0.32, 0.72, 0, 1);
z-index: 100;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow: hidden;
will-change: transform, height;
}
#panel.open { transform: translateY(0); }
#panel.dragging { transition: none !important; }
/* Drag handle */
.panel-handle {
width: 36px;
height: 5px;
border-radius: 3px;
background: {{mutedColor}};
opacity: 0.4;
margin: 10px auto 0;
flex-shrink: 0;
}
.panel-handle-zone {
padding: 6px 0 2px;
cursor: grab;
flex-shrink: 0;
touch-action: none;
}
/* Action buttons row */
.panel-actions {
display: flex;
gap: 0;
padding: 8px 12px 4px;
flex-shrink: 0;
border-bottom: 1px solid {{borderColor}};
}
.panel-actions button {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 8px 4px;
border: none;
background: transparent;
color: {{mutedColor}};
font-size: 10px;
font-family: inherit;
cursor: pointer;
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.panel-actions button:active {
background: {{borderColor}};
}
.panel-actions button svg {
width: 20px;
height: 20px;
fill: {{accentColor}};
}
.panel-actions button.destructive svg { fill: {{errorColor}}; }
/* Label header */
.panel-label {
padding: 12px 16px 4px;
flex-shrink: 0;
}
.panel-label .node-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px; border-radius: 12px;
padding: 14px 16px; font-size: 13px;
font-weight: 700;
color: #fff;
}
.panel-label .node-id {
font-size: 11px;
color: {{mutedColor}};
margin-left: 8px;
}
.panel-label .edge-type {
font-size: 15px;
font-weight: 700;
color: {{primaryColor}};
}
/* Scrollable properties */
.panel-props {
overflow-y: auto;
padding: 8px 16px 24px;
-webkit-overflow-scrolling: touch;
flex: 1;
}
.panel-props table {
width: 100%;
border-collapse: collapse;
}
.panel-props td {
padding: 6px 0;
font-size: 13px;
line-height: 1.4;
vertical-align: top;
border-bottom: 1px solid {{borderColor}};
}
.panel-props td.pk {
color: {{mutedColor}};
font-weight: 600;
white-space: nowrap;
padding-right: 12px;
width: 1%;
}
.panel-props td.pv {
color: {{primaryColor}};
word-break: break-word;
}
.panel-props tr:last-child td { border-bottom: none; }
.panel-props .no-props {
color: {{mutedColor}};
font-size: 13px;
font-style: italic;
}
/* JSON view */
.json-view-wrapper {
position: relative;
overflow: visible;
}
.json-view-wrapper pre {
background: {{borderColor}};
color: {{primaryColor}};
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 12px;
line-height: 1.5;
padding: 36px 12px 12px 12px;
border-radius: 8px;
overflow: auto;
max-height: 40vh;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
.json-copy-btn {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
width: 28px;
height: 28px;
border: none;
background: {{panelBg}};
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.8;
-webkit-tap-highlight-color: transparent;
}
.json-copy-btn:active { opacity: 1; }
.json-copy-btn svg {
width: 16px;
height: 16px;
fill: {{accentColor}};
}
/* Relations list */
.relations-list {
list-style: none;
padding: 0;
margin: 0;
}
.relations-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid {{borderColor}};
font-size: 13px;
line-height: 1.4;
}
.relations-list li:last-child { border-bottom: none; }
.rel-type {
font-weight: 600;
color: {{accentColor}};
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: {{borderColor}};
white-space: nowrap;
}
.rel-arrow {
color: {{mutedColor}};
font-size: 14px;
flex-shrink: 0;
}
.rel-node {
color: {{primaryColor}};
font-weight: 500;
word-break: break-word;
}
.rel-empty {
color: {{mutedColor}};
font-size: 13px;
font-style: italic;
}
/* Toast notification */
#toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: {{panelBg}};
color: {{primaryColor}};
border: 1px solid {{borderColor}};
padding: 8px 16px;
border-radius: 8px;
font-family: -apple-system, sans-serif; font-family: -apple-system, sans-serif;
font-size: 13px; font-size: 13px;
line-height: 1.5; opacity: 0;
max-height: 40%; transition: opacity 0.2s, transform 0.2s;
overflow-y: auto; z-index: 200;
z-index: 100; pointer-events: none;
} }
#popup .lbl { #toast.show {
font-size: 15px; opacity: 1;
font-weight: 600; transform: translateX(-50%) translateY(0);
margin-bottom: 6px;
color: #7EC8E3;
} }
#popup .prop { color: #ccc; }
#popup .prop b { color: #fff; }
</style> </style>
</head> </head>
<body> <body>
<canvas id="c"></canvas> <canvas id="c"></canvas>
<div id="popup"></div>
<!-- Backdrop to catch taps outside panel -->
<div id="panel-backdrop"></div>
<!-- Slide-up detail panel -->
<div id="panel">
<div class="panel-handle-zone" id="panel-handle-zone">
<div class="panel-handle"></div>
</div>
<div class="panel-actions" id="panel-actions"></div>
<div class="panel-label" id="panel-label"></div>
<div class="panel-props" id="panel-props"></div>
</div>
<!-- Toast -->
<div id="toast"></div>
<script> <script>
(function () { (function () {
@@ -51,7 +290,12 @@
var canvas = document.getElementById('c'); var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
var popup = document.getElementById('popup'); var panel = document.getElementById('panel');
var panelBackdrop = document.getElementById('panel-backdrop');
var panelActions = document.getElementById('panel-actions');
var panelLabel = document.getElementById('panel-label');
var panelProps = document.getElementById('panel-props');
var panelHandleZone = document.getElementById('panel-handle-zone');
var W = 0, H = 0, dpr = window.devicePixelRatio || 1; var W = 0, H = 0, dpr = window.devicePixelRatio || 1;
// ---- node/edge state ---- // ---- node/edge state ----
@@ -134,8 +378,6 @@
edges.forEach(function (e) { edges.forEach(function (e) {
a = nodeById[e.from]; b = nodeById[e.to]; a = nodeById[e.from]; b = nodeById[e.to];
if (!a || !b) return; if (!a || !b) return;
// leaf-to-hub: short spring (leaf stays close as petal)
// hub-to-hub: long spring (clusters separate)
var len; var len;
if (a.isHub && b.isHub) { if (a.isHub && b.isHub) {
len = HUB_SPRING; len = HUB_SPRING;
@@ -190,6 +432,35 @@
// ---- camera (pan + zoom, initial zoom based on node count) ---- // ---- camera (pan + zoom, initial zoom based on node count) ----
var initialZoom = N <= 10 ? 1 : Math.max(0.05, 1 / Math.sqrt(N / 10)); var initialZoom = N <= 10 ? 1 : Math.max(0.05, 1 / Math.sqrt(N / 10));
var panX = 0, panY = 0, zoom = initialZoom; var panX = 0, panY = 0, zoom = initialZoom;
var autoZoomEnabled = {{autoZoom}};
// Smoothly animate camera to center on a node
function zoomToNode(n, targetZoom, delay) {
if (!autoZoomEnabled) return;
function doZoom() {
targetZoom = targetZoom || Math.max(zoom, 1.2);
var startPanX = panX, startPanY = panY, startZoom = zoom;
// If panel is open, center in the visible area above the panel
var panelH = panelOpen ? (panel.offsetHeight || H * 0.55) : 0;
var visibleCenterY = (H - panelH) / 2;
var endPanX = W / 2 - n.x * targetZoom;
var endPanY = visibleCenterY - n.y * targetZoom;
var duration = 400, startTime = Date.now();
function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
function animate() {
var t = Math.min(1, (Date.now() - startTime) / duration);
var p = ease(t);
panX = startPanX + (endPanX - startPanX) * p;
panY = startPanY + (endPanY - startPanY) * p;
zoom = startZoom + (targetZoom - startZoom) * p;
draw();
if (t < 1) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
if (delay) setTimeout(doZoom, delay);
else doZoom();
}
// screen coords -> world coords // screen coords -> world coords
function toWorld(sx, sy) { function toWorld(sx, sy) {
@@ -257,7 +528,7 @@
ctx.stroke(); ctx.stroke();
} }
var lbl = n.label.length > 10 ? n.label.slice(0, 9) + '' : n.label; var lbl = n.label.length > 10 ? n.label.slice(0, 9) + '\u2026' : n.label;
ctx.fillStyle = '#fff'; ctx.fillStyle = '#fff';
ctx.font = 'bold 11px -apple-system, sans-serif'; ctx.font = 'bold 11px -apple-system, sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
@@ -267,57 +538,396 @@
ctx.restore(); ctx.restore();
} }
// ---- popup ---- // ---- panel ----
function buildPropsHtml(title) { var panelOpen = false;
var html = ''; var panelViewState = 'props'; // 'props', 'json', 'relations'
if (title) { var panelCurrentItem = null;
title.split(', ').forEach(function (pair) { var panelCurrentType = null; // 'node' or 'edge'
var idx = pair.indexOf(': ');
if (idx > -1) { // SVG icon paths
html += '<div class="prop"><b>' + escHtml(pair.slice(0, idx)) + ':</b> ' + escHtml(pair.slice(idx + 2)) + '</div>'; var ICONS = {
} else { copy: '<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>',
html += '<div class="prop">' + escHtml(pair) + '</div>'; json: '<svg viewBox="0 0 24 24"><path d="M5 3h2v2H5v5a2 2 0 01-2 2 2 2 0 012 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 00-2-2H0v-2h1a2 2 0 002-2V5a2 2 0 012-2m14 0a2 2 0 012 2v4a2 2 0 002 2h1v2h-1a2 2 0 00-2 2v4a2 2 0 01-2 2h-2v-2h2v-5a2 2 0 012-2 2 2 0 01-2-2V5h-2V3h2z"/></svg>',
} expand: '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15v-4H7v-2h4V7h2v4h4v2h-4v4h-2z"/></svg>',
collapse: '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11H7v-2h10v2z"/></svg>',
close: '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>',
props: '<svg viewBox="0 0 24 24"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>'
};
function parseProps(title) {
var props = [];
if (!title) return props;
title.split(', ').forEach(function (pair) {
var idx = pair.indexOf(': ');
if (idx > -1) {
props.push({ key: pair.slice(0, idx), value: pair.slice(idx + 2) });
} else if (pair.trim()) {
props.push({ key: '', value: pair });
}
});
return props;
}
function propsToText(label, props) {
var lines = [label];
props.forEach(function (p) {
lines.push(p.key ? p.key + ': ' + p.value : p.value);
});
return lines.join('\n');
}
function propsToJson(n) {
var obj = { id: n.id, label: n.label, properties: {} };
parseProps(n.title).forEach(function (p) {
if (p.key) obj.properties[p.key] = p.value;
});
return JSON.stringify(obj, null, 2);
}
function edgePropsToJson(e) {
var obj = { type: e.label, from: e.from, to: e.to, properties: {} };
parseProps(e.title).forEach(function (p) {
if (p.key) obj.properties[p.key] = p.value;
});
return JSON.stringify(obj, null, 2);
}
function showToast(msg) {
var t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(function () { t.classList.remove('show'); }, 1500);
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
showToast('Copied to clipboard');
}); });
} else { } else {
html += '<div class="prop">No properties</div>'; // Fallback for WebView
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('Copied to clipboard');
} }
}
function getRelationsForNode(nodeId) {
var rels = [];
edges.forEach(function (e) {
if (e.from === nodeId) {
var targetNode = nodeById[e.to];
rels.push({
type: e.label || 'RELATED',
direction: 'out',
nodeLabel: targetNode ? targetNode.label : String(e.to),
nodeId: e.to
});
}
if (e.to === nodeId) {
var sourceNode = nodeById[e.from];
rels.push({
type: e.label || 'RELATED',
direction: 'in',
nodeLabel: sourceNode ? sourceNode.label : String(e.from),
nodeId: e.from
});
}
});
return rels;
}
function buildRelationsHtml(nodeId) {
var rels = getRelationsForNode(nodeId);
if (rels.length === 0) {
return '<div class="rel-empty">Loading relations\u2026</div>';
}
var html = '<ul class="relations-list">';
rels.forEach(function (r) {
var arrow = r.direction === 'out' ? '\u2192' : '\u2190';
html += '<li>';
html += '<span class="rel-type">' + escHtml(r.type) + '</span>';
html += '<span class="rel-arrow">' + arrow + '</span>';
html += '<span class="rel-node">' + escHtml(r.nodeLabel) + '</span>';
html += '</li>';
});
html += '</ul>';
return html; return html;
} }
function showPopup(n) { function buildJsonViewHtml(jsonStr) {
return '<div class="json-view-wrapper">' +
'<button class="json-copy-btn" onclick="window._panelCopyJsonClipboard()">' + ICONS.copy + '</button>' +
'<pre>' + escHtml(jsonStr) + '</pre>' +
'</div>';
}
function buildActionsForView(type, item, viewState) {
var html = '';
// Copy as text
html += '<button onclick="window._panelCopyText()">' + ICONS.copy + '<span>Copy</span></button>';
// JSON / Properties toggle
if (viewState === 'json') {
html += '<button onclick="window._panelShowProps()">' + ICONS.props + '<span>Properties</span></button>';
} else {
html += '<button onclick="window._panelShowJson()">' + ICONS.json + '<span>JSON</span></button>';
}
if (type === 'node') {
if (viewState === 'relations') {
// Show Properties button to go back
html += '<button onclick="window._panelShowProps()">' + ICONS.props + '<span>Properties</span></button>';
} else {
// Expand / collapse relations
var isExpanded = !!expandedBy[item.id];
if (isExpanded) {
html += '<button onclick="window._panelCollapse()">' + ICONS.collapse + '<span>Collapse</span></button>';
} else {
html += '<button onclick="window._panelExpand()">' + ICONS.expand + '<span>Relations</span></button>';
}
}
}
// Close
html += '<button class="destructive" onclick="window._panelClose()">' + ICONS.close + '<span>Close</span></button>';
return html;
}
function buildPropsHtml(title) {
var props = parseProps(title);
if (!props.length) return '<div class="no-props">No properties</div>';
var html = '<table>';
props.forEach(function (p) {
html += '<tr><td class="pk">' + escHtml(p.key) + '</td><td class="pv">' + escHtml(p.value) + '</td></tr>';
});
html += '</table>';
return html;
}
function updatePanelContent() {
if (!panelCurrentItem) return;
var item = panelCurrentItem;
var type = panelCurrentType;
if (panelViewState === 'json') {
var jsonStr = type === 'node' ? propsToJson(item) : edgePropsToJson(item);
panelProps.innerHTML = buildJsonViewHtml(jsonStr);
window._panelCopyJsonClipboard = function () {
copyToClipboard(jsonStr);
};
} else if (panelViewState === 'relations' && type === 'node') {
panelProps.innerHTML = buildRelationsHtml(item.id);
} else {
panelProps.innerHTML = buildPropsHtml(item.title);
}
panelActions.innerHTML = buildActionsForView(type, item, panelViewState);
wireUpActions(type, item);
}
function wireUpActions(type, item) {
window._panelCopyText = function () {
var label = type === 'node' ? item.label : (item.label || 'Relationship');
copyToClipboard(propsToText(label, parseProps(item.title)));
};
window._panelShowJson = function () {
panelViewState = 'json';
updatePanelContent();
};
window._panelShowProps = function () {
panelViewState = 'props';
updatePanelContent();
};
window._panelClose = function () { hidePanel(); };
if (type === 'node') {
window._panelExpand = function () {
// Trigger expand
toggleExpandCollapse(item);
// Switch to relations view
panelViewState = 'relations';
updatePanelContent();
// After expand completes, the addGraphData callback will refresh
setTimeout(function () {
if (panelOpen && panelViewState === 'relations') {
updatePanelContent();
}
}, 500);
};
window._panelCollapse = function () {
toggleExpandCollapse(item);
setTimeout(function () {
updatePanelContent();
}, 300);
};
}
}
function showNodePanel(n) {
selected = n; selected = n;
selectedEdge = null; selectedEdge = null;
var html = '<div class="lbl">' + escHtml(n.label) + '</div>'; panelCurrentItem = n;
html += buildPropsHtml(n.title); panelCurrentType = 'node';
popup.innerHTML = html; panelViewState = 'props';
popup.style.display = 'block';
// Label header
panelLabel.innerHTML = '<span class="node-badge" style="background:' + n.color + '">' + escHtml(n.label) + '</span>' +
'<span class="node-id">ID: ' + n.id + '</span>';
updatePanelContent();
openPanel();
zoomToNode(n);
draw(); draw();
} }
function showEdgePopup(e) { function showEdgePanel(e) {
selectedEdge = e; selectedEdge = e;
selected = null; selected = null;
var html = '<div class="lbl" style="color:#E8A838">' + escHtml(e.label || 'Relationship') + '</div>'; panelCurrentItem = e;
html += buildPropsHtml(e.title); panelCurrentType = 'edge';
popup.innerHTML = html; panelViewState = 'props';
popup.style.display = 'block';
// Label
panelLabel.innerHTML = '<span class="edge-type">' + escHtml(e.label || 'Relationship') + '</span>' +
'<span class="node-id" style="margin-left:8px">' + e.from + ' \u2192 ' + e.to + '</span>';
updatePanelContent();
openPanel();
draw(); draw();
} }
function hidePopup() { function notifyApp(url) {
var f = document.createElement('iframe');
f.style.display = 'none';
f.src = url;
document.body.appendChild(f);
setTimeout(function () { document.body.removeChild(f); }, 200);
}
function openPanel() {
panelOpen = true;
panel.style.height = '55%';
panelBackdrop.style.display = 'block';
panel.offsetHeight;
panel.classList.add('open');
notifyApp('app://panelopen');
}
function hidePanel() {
selected = null; selected = null;
selectedEdge = null; selectedEdge = null;
popup.style.display = 'none'; panelOpen = false;
panelCurrentItem = null;
panelCurrentType = null;
panelViewState = 'props';
panel.classList.remove('open');
panel.style.transform = '';
panel.style.height = '';
panelBackdrop.style.display = 'none';
draw(); draw();
notifyApp('app://panelclose');
} }
panelBackdrop.addEventListener('click', function (e) { e.stopPropagation(); hidePanel(); });
panelBackdrop.addEventListener('touchend', function (e) { e.stopPropagation(); hidePanel(); });
// ---- panel drag (up to expand, down to dismiss) ----
var panelDragStartY = 0;
var panelDragDeltaY = 0;
var panelIsDragging = false;
var panelStartHeight = 0;
var PANEL_MIN_H = 120;
function panelDragStart(clientY, e) {
if (!panelOpen) return;
panelDragStartY = clientY;
panelDragDeltaY = 0;
panelStartHeight = panel.offsetHeight;
panelIsDragging = true;
panel.classList.add('dragging');
if (e) e.stopPropagation();
}
panelHandleZone.addEventListener('touchstart', function (e) {
panelDragStart(e.touches[0].clientY, e);
}, { passive: false });
panelHandleZone.addEventListener('mousedown', function (e) {
panelDragStart(e.clientY, e);
});
function panelDragMove(clientY) {
if (!panelIsDragging) return;
var dy = clientY - panelDragStartY;
panelDragDeltaY = dy;
if (dy >= 0) {
// Dragging down — translate panel down (toward dismiss)
panel.style.height = panelStartHeight + 'px';
panel.style.transform = 'translateY(' + dy + 'px)';
} else {
// Dragging up — expand panel height
var newH = Math.min(panelStartHeight - dy, H * 0.92);
newH = Math.max(newH, PANEL_MIN_H);
panel.style.height = newH + 'px';
panel.style.transform = 'translateY(0)';
}
}
function panelDragEnd() {
if (!panelIsDragging) return;
panelIsDragging = false;
panel.classList.remove('dragging');
if (panelDragDeltaY > 80) {
hidePanel();
} else {
// Keep the new height if dragged up, snap translate back
panel.style.transform = '';
}
}
document.addEventListener('touchmove', function (e) {
if (panelIsDragging) {
panelDragMove(e.touches[0].clientY);
e.preventDefault();
}
}, { passive: false });
document.addEventListener('touchend', function () {
if (panelIsDragging) panelDragEnd();
});
document.addEventListener('mousemove', function (e) {
if (panelIsDragging) {
panelDragMove(e.clientY);
e.preventDefault();
}
});
document.addEventListener('mouseup', function () {
if (panelIsDragging) panelDragEnd();
});
document.addEventListener('mouseup', function (e) {
if (panelIsDragging) {
panelDragEnd();
}
});
function escHtml(s) { function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
// ---- expand / collapse ---- // ---- expand / collapse ----
var lastTapTime = 0, lastTapNode = null; var lastTapTime = 0, lastTapNode = null, singleTapTimer = null;
var expandedBy = {}; // nodeId -> [childIds added by expanding that node] var expandedBy = {}; // nodeId -> [childIds added by expanding that node]
// initial nodes are never collapsible // initial nodes are never collapsible
@@ -346,19 +956,13 @@
} }
function expandNode(n) { function expandNode(n) {
var f = document.createElement('iframe'); notifyApp('app://expand?nodeId=' + n.id);
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) { function collapseNode(n) {
var childIds = expandedBy[n.id]; var childIds = expandedBy[n.id];
if (!childIds) return; if (!childIds) return;
// only remove children that aren't initial nodes and aren't
// also owned by another still-expanded node
var otherOwned = {}; var otherOwned = {};
Object.keys(expandedBy).forEach(function (ownerId) { Object.keys(expandedBy).forEach(function (ownerId) {
if (ownerId == n.id) return; if (ownerId == n.id) return;
@@ -370,7 +974,6 @@
if (!initialNodeIds[cid] && !otherOwned[cid]) toRemove[cid] = true; 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) { Object.keys(toRemove).forEach(function (rid) {
if (expandedBy[rid]) { if (expandedBy[rid]) {
expandedBy[rid].forEach(function (cid) { expandedBy[rid].forEach(function (cid) {
@@ -380,19 +983,16 @@
} }
}); });
// remove edges connected to removed nodes
edges = edges.filter(function (e) { edges = edges.filter(function (e) {
return !toRemove[e.from] && !toRemove[e.to]; return !toRemove[e.from] && !toRemove[e.to];
}); });
// remove nodes
nodes = nodes.filter(function (nd) { return !toRemove[nd.id]; }); nodes = nodes.filter(function (nd) { return !toRemove[nd.id]; });
Object.keys(toRemove).forEach(function (rid) { delete nodeById[rid]; }); Object.keys(toRemove).forEach(function (rid) { delete nodeById[rid]; });
delete expandedBy[n.id]; delete expandedBy[n.id];
// hide popup if the selected node was removed if (selected && toRemove[selected.id]) hidePanel();
if (selected && toRemove[selected.id]) hidePopup();
recomputeDegrees(); recomputeDegrees();
resumeLoop(); resumeLoop();
@@ -407,7 +1007,7 @@
newNodes.forEach(function (d) { newNodes.forEach(function (d) {
if (nodeById[d.id]) { if (nodeById[d.id]) {
addedIds.push(d.id); // track even existing ones as children addedIds.push(d.id);
return; return;
} }
var angle = Math.random() * 2 * Math.PI; var angle = Math.random() * 2 * Math.PI;
@@ -429,13 +1029,22 @@
}); });
if (exists) return; if (exists) return;
if (!nodeById[d.from] || !nodeById[d.to]) return; if (!nodeById[d.from] || !nodeById[d.to]) return;
edges.push({ from: d.from, to: d.to, label: d.label || '' }); edges.push({ from: d.from, to: d.to, label: d.label || '', title: d.title || '' });
}); });
expandedBy[originId] = addedIds; expandedBy[originId] = addedIds;
recomputeDegrees(); recomputeDegrees();
resumeLoop(); resumeLoop();
// Zoom to the expanded node
var origin = nodeById[originId];
if (origin) zoomToNode(origin, null, 800);
// Update panel if still showing this node
if (selected && selected.id === originId && panelOpen) {
updatePanelContent();
}
}; };
// ---- interaction handling (touch + mouse/pointer) ---- // ---- interaction handling (touch + mouse/pointer) ----
@@ -444,21 +1053,16 @@
function notifyInteraction() { function notifyInteraction() {
if (interacted) return; if (interacted) return;
interacted = true; interacted = true;
var f = document.createElement('iframe'); notifyApp('app://interaction');
f.style.display = 'none';
f.src = 'app://interaction';
document.body.appendChild(f);
setTimeout(function () { document.body.removeChild(f); }, 200);
} }
function edgeAt(sx, sy) { function edgeAt(sx, sy) {
var w = toWorld(sx, sy); var w = toWorld(sx, sy);
var threshold = 12 / zoom; // tap tolerance in world units var threshold = 12 / zoom;
var best = null, bestDist = threshold; var best = null, bestDist = threshold;
edges.forEach(function (e) { edges.forEach(function (e) {
var a = nodeById[e.from], b = nodeById[e.to]; var a = nodeById[e.from], b = nodeById[e.to];
if (!a || !b) return; if (!a || !b) return;
// point-to-segment distance
var dx = b.x - a.x, dy = b.y - a.y; var dx = b.x - a.x, dy = b.y - a.y;
var lenSq = dx * dx + dy * dy; var lenSq = dx * dx + dy * dy;
if (lenSq === 0) return; if (lenSq === 0) return;
@@ -487,6 +1091,7 @@
function handleStart(e) { function handleStart(e) {
if (e.touches) e.preventDefault(); if (e.touches) e.preventDefault();
if (e.touches && e.touches.length >= 2) return; // pinch handled separately
notifyInteraction(); notifyInteraction();
var p = coordFromEvent(e); var p = coordFromEvent(e);
ptrStartX = ptrEndX = p.x; ptrStartX = ptrEndX = p.x;
@@ -524,23 +1129,30 @@
if (!moved && dt < 300) { if (!moved && dt < 300) {
var hit = nodeAt(ptrStartX, ptrStartY); var hit = nodeAt(ptrStartX, ptrStartY);
var now = Date.now(); var now = Date.now();
// double-tap detection — toggle expand/collapse // double-tap detection — only expand/collapse, no panel
if (hit && hit === lastTapNode && (now - lastTapTime) < 400) { if (hit && hit === lastTapNode && (now - lastTapTime) < 400) {
lastTapNode = null; lastTapTime = 0; lastTapNode = null; lastTapTime = 0;
if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
toggleExpandCollapse(hit); toggleExpandCollapse(hit);
} else if (hit) { } else if (hit) {
lastTapNode = hit; lastTapTime = now; lastTapNode = hit; lastTapTime = now;
if (hit === selected) hidePopup(); // Delay panel open to distinguish from double-tap
else showPopup(hit); var tappedNode = hit;
if (singleTapTimer) clearTimeout(singleTapTimer);
singleTapTimer = setTimeout(function () {
singleTapTimer = null;
if (tappedNode === selected && panelOpen) hidePanel();
else showNodePanel(tappedNode);
}, 350);
} else { } else {
lastTapNode = null; lastTapTime = 0; lastTapNode = null; lastTapTime = 0;
// no node hit — check if an edge was tapped if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
var hitEdge = edgeAt(ptrStartX, ptrStartY); var hitEdge = edgeAt(ptrStartX, ptrStartY);
if (hitEdge) { if (hitEdge) {
if (hitEdge === selectedEdge) hidePopup(); if (hitEdge === selectedEdge && panelOpen) hidePanel();
else showEdgePopup(hitEdge); else showEdgePanel(hitEdge);
} else { } else {
hidePopup(); if (panelOpen) hidePanel();
} }
} }
} }
@@ -566,10 +1178,6 @@
canvas.addEventListener('mousemove', handleMove); canvas.addEventListener('mousemove', handleMove);
canvas.addEventListener('mouseup', handleEnd); 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 ---- // ---- zoom: scroll wheel ----
canvas.addEventListener('wheel', function (e) { canvas.addEventListener('wheel', function (e) {
e.preventDefault(); e.preventDefault();
@@ -577,7 +1185,6 @@
var mx = e.clientX - rect.left, my = e.clientY - rect.top; var mx = e.clientX - rect.left, my = e.clientY - rect.top;
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
var newZoom = Math.min(Math.max(zoom * factor, 0.1), 10); var newZoom = Math.min(Math.max(zoom * factor, 0.1), 10);
// zoom toward mouse position
panX = mx - (mx - panX) * (newZoom / zoom); panX = mx - (mx - panX) * (newZoom / zoom);
panY = my - (my - panY) * (newZoom / zoom); panY = my - (my - panY) * (newZoom / zoom);
zoom = newZoom; zoom = newZoom;
@@ -638,14 +1245,12 @@
function init() { function init() {
resize(); resize();
// WebView may not have layout yet — retry until we get real dimensions
if ((W <= 1 || H <= 1) && initRetries < 20) { if ((W <= 1 || H <= 1) && initRetries < 20) {
initRetries++; initRetries++;
setTimeout(init, 50); setTimeout(init, 50);
return; return;
} }
placeInitial(); placeInitial();
// center the camera on the graph with the pre-computed zoom
panX = W / 2 - (W / 2) * zoom; panX = W / 2 - (W / 2) * zoom;
panY = H / 2 - (H / 2) * zoom; panY = H / 2 - (H / 2) * zoom;
loop(); loop();
@@ -656,7 +1261,6 @@
draw(); draw();
}); });
// Use rAF to ensure the browser has laid out the canvas before we read its size
requestAnimationFrame(function () { requestAnimationFrame(function () {
requestAnimationFrame(init); requestAnimationFrame(init);
}); });

View File

@@ -5,6 +5,15 @@
<UseMaui>true</UseMaui> <UseMaui>true</UseMaui>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>disable</Nullable> <Nullable>disable</Nullable>
<MauiEnableXamlCBindingWithSourceCompilation>true</MauiEnableXamlCBindingWithSourceCompilation>
<!-- XC0023: elements inside DataTemplates that bind to both the item model and the parent
page ViewModel via Source={x:Reference} cannot share a single compiled DataType.
x:DataType="{x:Null}" on those elements is intentional; suppress the hint. -->
<NoWarn>$(NoWarn);XC0023</NoWarn>
<!-- CS0618: ViewCell is deprecated but converting ListView to CollectionView is a larger
refactor. The MAUI XAML source generator emits this on the generated partial class
for ConnectionCell/LicenseCell which we cannot annotate directly. -->
<NoWarn>$(NoWarn);CS0618</NoWarn>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -15,6 +24,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.20" /> <PackageReference Include="Microsoft.Maui.Controls" Version="10.0.20" />
<PackageReference Include="Neo4jClient" Version="4.1.18" /> <PackageReference Include="Neo4jClient" Version="4.1.18" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

24
landing/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

43
landing/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

8
landing/astro.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
// @ts-check
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://pocketgraph.app',
integrations: [sitemap()],
});

4375
landing/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
landing/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "pocketgraph",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.2",
"astro": "^7.0.2"
}
}

2973
landing/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
landing/public/demo.mp4 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
landing/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

BIN
landing/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
landing/public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "PocketGraph",
"short_name": "PocketGraph",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,81 @@
---
const APP_STORE_URL = "https://apps.apple.com/nl/app/pocketgraph/id1604368926";
const PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=nl.resoftware.pocketgraph";
---
<footer>
<div class="section-inner footer-inner">
<div class="footer-brand">
<img src="/logo.png" alt="PocketGraph" class="footer-icon" />
<span class="footer-name">PocketGraph</span>
</div>
<div class="footer-links">
<a href={APP_STORE_URL} target="_blank" rel="noopener">App Store</a>
<a href={PLAY_STORE_URL} target="_blank" rel="noopener">Google Play</a>
<a href="https://github.com/resoftware/pocketgraph" target="_blank" rel="noopener">GitHub</a>
<a href="https://resoftware.nl/contact" target="_blank" rel="noopener">Support</a>
<a href="/privacy">Privacy Policy</a>
</div>
<p class="footer-copy mono">
&copy; {new Date().getFullYear()} Re: Software B.V. &middot; Neo4j&reg; is a registered trademark of Neo4j, Inc. &middot; Memgraph&reg; is a registered trademark of Memgraph Ltd.
</p>
</div>
</footer>
<style>
footer {
position: relative;
z-index: 1;
border-top: 1px solid rgba(226, 226, 226, 0.1);
padding: 2.5rem 0;
}
.section-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 2rem;
}
.footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1.5rem;
}
.footer-brand {
display: flex;
align-items: center;
gap: 10px;
}
.footer-icon {
width: 40px; height: 40px;
}
.footer-name {
font-weight: 700;
font-size: 0.95rem;
color: #e8e8e8;
}
.footer-links {
display: flex;
gap: 2rem;
}
.footer-links a {
font-size: 0.85rem;
color: #9a9a9a;
text-decoration: none;
transition: color 0.2s;
}
.footer-links a:hover { color: #e2e2e2; }
.footer-copy {
font-size: 0.7rem;
color: #333333;
letter-spacing: 0.02em;
width: 100%;
text-align: right;
}
.mono { font-family: 'IBM Plex Mono', monospace; }
@media (max-width: 900px) {
.footer-inner { flex-direction: column; align-items: flex-start; }
.footer-links { flex-direction: column; gap: 0.6rem; }
.footer-copy { text-align: left; }
}
</style>

View File

@@ -0,0 +1,34 @@
---
interface Props {
title?: string;
description?: string;
canonical?: string;
}
const {
title = 'PocketGraph — Neo4j & Memgraph Client',
description = 'Connect to your Neo4j or Memgraph database from iPhone, iPad, and Android. Full Cypher editor, graph visualization, and Bolt protocol — in your pocket.',
canonical,
} = Astro.props;
const siteUrl = 'https://pocketgraph.app';
const ogImage = `${siteUrl}/og.png`;
const url = canonical ? `${siteUrl}${canonical}` : siteUrl;
---
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content={url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<meta property="og:site_name" content="PocketGraph" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImage} />

View File

@@ -0,0 +1,84 @@
---
---
<nav id="nav">
<div class="nav-inner">
<a href="/" class="nav-logo">
<img src="/logo.png" alt="PocketGraph icon" class="nav-icon" />
<span>PocketGraph</span>
</a>
<a href="#download" class="nav-dl">
Download
</a>
</div>
</nav>
<style>
#nav {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
padding: 0 2rem;
height: 80px;
display: flex;
align-items: center;
transition: background 0.3s, border-color 0.3s;
border-bottom: 1px solid transparent;
}
#nav.scrolled {
background: rgba(12, 12, 12, 0.9);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-color: rgba(226, 226, 226, 0.1);
}
.nav-inner {
max-width: 1120px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-logo {
display: flex;
align-items: center;
gap: 14px;
text-decoration: none;
color: #e8e8e8;
font-weight: 700;
font-size: 1.25rem;
letter-spacing: -0.02em;
margin-left: 0.75rem;
}
.nav-icon {
width: 56px; height: 56px;
display: block;
}
.nav-dl {
font-family: 'Syne', sans-serif;
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.04em;
text-transform: uppercase;
text-decoration: none;
color: #e2e2e2;
border: 1px solid #e2e2e2;
padding: 0.45rem 1.1rem;
border-radius: 2px;
transition: background 0.2s, color 0.2s;
}
.nav-dl:hover { background: #e2e2e2; color: #0c0c0c; }
@media (max-width: 480px) {
#nav { padding: 0 1.25rem; height: 64px; }
.nav-icon { width: 40px; height: 40px; }
.nav-logo { font-size: 1rem; gap: 10px; margin-left: 0; }
.nav-logo span { display: none; }
}
</style>
<script is:inline>
const nav = document.getElementById('nav');
window.addEventListener('scroll', () => {
nav?.classList.toggle('scrolled', window.scrollY > 20);
}, { passive: true });
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,233 @@
---
import Nav from '../components/Nav.astro';
import Meta from '../components/Meta.astro';
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<Meta
title="Privacy Policy — PocketGraph"
description="Privacy policy for PocketGraph, the Neo4j & Memgraph client for iPhone, iPad, and Android."
canonical="/privacy"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;1,300&display=swap" rel="stylesheet" />
<script src="https://rybbit.resoftware.work/api/script.js" data-site-id="d2f58ef3c722" defer></script>
</head>
<body>
<Nav />
<main>
<div class="content">
<p class="mono label">// legal</p>
<h1>Privacy Policy</h1>
<p class="meta mono">Re: Software B.V. &middot; Last updated: March 2026</p>
<section>
<h2>Overview</h2>
<p>PocketGraph is a graph database client for iPhone, iPad, and Android. We take your privacy seriously. This policy explains what data we collect, why, and how it is used.</p>
<p>The short version: <strong>PocketGraph does not collect, store, or transmit any of your database credentials or query data.</strong> All connections are made directly from your device to your database.</p>
</section>
<section>
<h2>Data We Do Not Collect</h2>
<ul>
<li>Database hostnames, ports, usernames, or passwords</li>
<li>Cypher queries you write or execute</li>
<li>Query results or any data stored in your database</li>
<li>Graph visualizations or schemas</li>
</ul>
<p>This data never leaves your device and is never sent to Re: Software or any third party.</p>
</section>
<section>
<h2>Data Stored on Your Device</h2>
<p>PocketGraph stores the following locally on your device only:</p>
<ul>
<li>Connection profiles (host, port, username) — stored in the iOS Keychain or Android Keystore</li>
<li>Query history — stored locally, never synced externally</li>
<li>App preferences and settings</li>
</ul>
<p>This data is protected by system-level encryption (iOS Keychain / Android Keystore) and is not accessible to Re: Software.</p>
</section>
<section>
<h2>Analytics</h2>
<p>This website (pocketgraph.app) uses <a href="https://rybbit.io" target="_blank" rel="noopener">Rybbit</a>, a privacy-friendly analytics tool, to collect anonymised page view data. No cookies are set, no personal data is collected, and no cross-site tracking occurs.</p>
<p>The PocketGraph app itself does not include any analytics SDK.</p>
</section>
<section>
<h2>Network Connections</h2>
<p>PocketGraph connects directly from your device to your Neo4j or Memgraph database using the Bolt protocol over SSL/TLS. No traffic is routed through Re: Software servers. Your database credentials are used solely to authenticate with your own database.</p>
</section>
<section>
<h2>Purchases</h2>
<p>PocketGraph is sold as a one-time purchase through the Apple App Store and Google Play Store. All payment processing is handled by Apple or Google respectively. Re: Software does not receive or store any payment or billing information.</p>
</section>
<section>
<h2>Children</h2>
<p>PocketGraph is not directed at children under 13 and we do not knowingly collect data from children.</p>
</section>
<section>
<h2>Changes to This Policy</h2>
<p>If this policy changes materially, we will update the date at the top of this page. Continued use of the app constitutes acceptance of the updated policy.</p>
</section>
<section>
<h2>Contact</h2>
<p>Questions about this policy? Reach us at <a href="https://resoftware.nl/contact" target="_blank" rel="noopener">resoftware.nl/contact</a>.</p>
</section>
</div>
</main>
<footer>
<div class="section-inner footer-inner">
<a href="/" class="footer-back mono">&larr; Back to PocketGraph</a>
<p class="footer-copy mono">&copy; {new Date().getFullYear()} Re: Software B.V.</p>
</div>
</footer>
<style>
:root {
--bg: #0c0c0c;
--text: #e8e8e8;
--muted: #9a9a9a;
--border: rgba(226, 226, 226, 0.1);
--font-sans: 'Syne', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.6;
}
/* MAIN */
main {
max-width: 1120px;
margin: 0 auto;
padding: 5rem 2rem 6rem;
}
.label {
font-size: 0.75rem;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 1rem;
}
h1 {
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 800;
letter-spacing: -0.03em;
color: #fff;
margin-bottom: 0.5rem;
}
.meta {
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 3.5rem;
}
section {
margin-bottom: 2.5rem;
padding-bottom: 2.5rem;
border-bottom: 1px solid var(--border);
}
section:last-of-type { border-bottom: none; }
h2 {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.02em;
color: #fff;
margin-bottom: 0.75rem;
}
p {
color: #aaaaaa;
font-size: 0.95rem;
line-height: 1.75;
margin-bottom: 0.75rem;
}
p:last-child { margin-bottom: 0; }
strong { color: var(--text); font-weight: 600; }
ul {
list-style: none;
margin: 0.5rem 0 0.75rem 0;
}
ul li {
color: #aaaaaa;
font-size: 0.95rem;
line-height: 1.75;
padding-left: 1.25rem;
position: relative;
}
ul li::before {
content: '';
position: absolute;
left: 0;
color: var(--muted);
}
a {
color: var(--text);
text-underline-offset: 3px;
}
a:hover { color: #fff; }
.mono { font-family: var(--font-mono); }
/* FOOTER */
footer {
border-top: 1px solid var(--border);
padding: 2rem 0;
}
.section-inner { max-width: 1120px; margin: 0 auto; padding: 0 2rem; }
.footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.footer-back {
font-size: 0.8rem;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.02em;
transition: color 0.2s;
}
.footer-back:hover { color: var(--text); }
.footer-copy {
font-size: 0.7rem;
color: #333333;
letter-spacing: 0.02em;
}
</style>
</body>
</html>

5
landing/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}