39 Commits

Author SHA1 Message Date
Trevi Awater
d4da958130 Merge pull request #3 from resoftware/fix-appstore-runner-xcode
Fix App Store deploy: use macos-26 runner with Xcode 26.5
2026-09-21 13:40:04 +02:00
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:
build-and-deploy:
runs-on: macos-15
runs-on: macos-26
timeout-minutes: 45
steps:
@@ -21,7 +21,7 @@ jobs:
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.2"
xcode-version: "26.5"
- name: Setup .NET
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
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)
@@ -48,6 +50,36 @@ dotnet test Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.Tests/
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
- .NET MAUI

View File

@@ -8,12 +8,15 @@ using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Android.Content;
using Android.Content.Res;
using Android.Graphics;
using Android.Graphics.Drawables;
using Android.Text;
using Android.Text.Style;
using Android.Views;
using Android.Views.InputMethods;
using Android.Widget;
using AndroidX.Core.View;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
using Xamarin.Neo4j.Controls;
@@ -49,6 +52,9 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
"FALSE", "NULL", "TRUE"
];
private LinearLayout _toolbar;
private bool _toolbarAttached;
protected override void ConnectHandler(MauiAppCompatEditText platformView)
{
base.ConnectHandler(platformView);
@@ -58,43 +64,136 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
| InputTypes.TextFlagMultiLine
| InputTypes.TextFlagNoSuggestions;
// Keyboard toolbar with symbol keys and Execute button
var toolbar = BuildToolbar(platformView);
// Build the keyboard toolbar
_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) =>
toolbar.Visibility = e.HasFocus ? ViewStates.Visible : ViewStates.Gone;
{
if (!e.HasFocus)
_toolbar.Visibility = ViewStates.Gone;
};
// Syntax highlighting
platformView.AddTextChangedListener(new CypherTextWatcher(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)
{
var context = platformView.Context;
var density = context.Resources.DisplayMetrics.Density;
var toolbar = new LinearLayout(context)
{
Orientation = Orientation.Horizontal,
Orientation = global::Android.Widget.Orientation.Horizontal,
LayoutParameters = new LinearLayout.LayoutParams(
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)
{
LayoutParameters = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f)
LayoutParameters = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)
};
scrollView.HorizontalScrollBarEnabled = false;
var keyRow = new LinearLayout(context) { Orientation = Orientation.Horizontal };
foreach (var key in new[] { "(", ")", "[", "]", ":", "-", "->", "<-" })
var keyRow = new LinearLayout(context)
{
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 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) =>
{
var start = Math.Max(platformView.SelectionStart, 0);
@@ -105,12 +204,34 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
}
scrollView.AddView(keyRow);
toolbar.AddView(scrollView);
pillContainer.AddView(scrollView);
toolbar.AddView(pillContainer);
// Execute button
var executeBtn = CreatePillButton(context, "Execute");
// Spacer
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.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) =>
{
if (VirtualView is QueryEditor queryEditor)
@@ -120,33 +241,23 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
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);
// Attach toolbar to the parent view hierarchy
platformView.ViewTreeObserver.GlobalLayout += (s, e) =>
{
if (platformView.Parent is ViewGroup parent && toolbar.Parent == null)
parent.AddView(toolbar);
};
toolbar.Visibility = ViewStates.Gone;
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 ───────────────────────────────────────────────
internal static void HighlightSyntax(MauiAppCompatEditText platformView, IEnumerable<string> keywords)
@@ -191,6 +302,15 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
// ── 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 readonly MauiAppCompatEditText _view;
@@ -210,9 +330,50 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
{
if (_updating) return;
_updating = true;
try { HighlightSyntax(_view, _keywords); }
try
{
AutoCapitalizeKeywords(_view, _keywords);
HighlightSyntax(_view, _keywords);
}
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
? AColor.ParseColor("#0c0c0c")
: AColor.ParseColor("#f5f5f5");
#pragma warning disable CA1422
Window.SetStatusBarColor(statusBarColor);
#pragma warning restore CA1422
var controller = new WindowInsetsControllerCompat(Window, Window.DecorView);
controller.AppearanceLightStatusBars = !isDark;
@@ -53,21 +55,24 @@ namespace Xamarin.Neo4j.Android
{
base.OnWindowFocusChanged(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
// 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;
for (var i = 0; i < parent.ChildCount; i++)
{
var child = parent.GetChildAt(i);
if (child is Toolbar toolbar)
{
toolbar.OverflowIcon?.SetTint(AColor.White);
toolbar.NavigationIcon?.SetTint(AColor.White);
}
else if (child is ViewGroup vg)
TintToolbarOverflow(vg);
TintToolbarIcons(vg);
}
}
}

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="1"
android:versionName="1.0"
android:versionCode="2"
android:versionName="2.0.0"
package="nl.resoftware.pocketgraph">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="36" />
<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.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Android.Runtime;
using Java.Security;
using Java.Security.Cert;
using Javax.Net.Ssl;
@@ -25,8 +26,9 @@ namespace Xamarin.Neo4j
var factory = TrustManagerFactory.GetInstance(TrustManagerFactory.DefaultAlgorithm);
factory.Init((KeyStore)null);
_androidTrustManager = factory.GetTrustManagers()
.OfType<IX509TrustManager>()
.First();
.OfType<Java.Lang.Object>()
.Select(m => m.JavaCast<IX509TrustManager>())
.First(m => m != null);
}
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 info = context.PackageManager.GetPackageInfo(context.PackageName, 0);
#pragma warning disable CS0618
#pragma warning disable CS0618, CA1416, CA1422
return Build.VERSION.SdkInt >= BuildVersionCodes.P
? info.LongVersionCode.ToString()
: info.VersionCode.ToString();
#pragma warning restore CS0618
#pragma warning restore CS0618, CA1416, CA1422
}
}
}

View File

@@ -12,6 +12,8 @@
<Nullable>disable</Nullable>
<!-- Embed all assemblies in the APK so plain adb install works without Fast Deployment -->
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
<ApplicationDisplayVersion>2.0.0</ApplicationDisplayVersion>
<ApplicationVersion>2</ApplicationVersion>
</PropertyGroup>
<ItemGroup>
@@ -32,6 +34,10 @@
Link="Assets\logo.png" />
<AndroidAsset Include="..\Xamarin.Neo4j.iOS\Resources\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>
@@ -47,4 +53,56 @@
<ProjectReference Include="..\Xamarin.Neo4j\Xamarin.Neo4j.csproj" />
</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>

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 CoreGraphics;
using Foundation;
using Microsoft.Maui;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
@@ -22,6 +23,15 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
{
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 =
[
// Clauses
@@ -60,14 +70,16 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
const float accessoryHeight = 58f;
const float buttonWidth = 33f;
const float executeButtonWidth = 90f;
const float padding = 8f;
const float pillHeight = 38f;
var keys = new[]
{
("(", "("), (")", ")"), ("[", "["), ("]", "]"),
(":", ":"), ("-", "-"), ("\u2192", "->"), ("\u2190", "<-")
("{", "{"), ("}", "}"), (":", ":"), ("-", "-"),
("\u2192", "->"), ("\u2190", "<-"), ("\"", "\""),
("'", "'"), (".", "."), ("=", "="), ("*", "*"), ("$", "$")
};
// Outer accessory — clear so system background shows through
@@ -112,50 +124,42 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
scrollView.BottomAnchor.ConstraintEqualTo(pillContainer.BottomAnchor),
});
// Execute button — blue pill
var executeBtn = new UIButton(UIButtonType.System);
executeBtn.TranslatesAutoresizingMaskIntoConstraints = false;
executeBtn.SetTitle("Execute", UIControlState.Normal);
executeBtn.SetTitleColor(UIColor.White, UIControlState.Normal);
executeBtn.BackgroundColor = UIColor.SystemBlue;
executeBtn.Layer.CornerRadius = pillHeight / 2f;
executeBtn.Layer.MasksToBounds = true;
executeBtn.TouchUpInside += (s, e) =>
{
if (VirtualView is QueryEditor queryEditor)
{
queryEditor.RaiseExecuteClicked();
platformView.ResignFirstResponder();
}
};
accessoryView.AddSubview(pillContainer);
accessoryView.AddSubview(executeBtn);
NSLayoutConstraint.ActivateConstraints(new[]
{
executeBtn.TrailingAnchor.ConstraintEqualTo(accessoryView.TrailingAnchor, -padding),
executeBtn.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor),
executeBtn.HeightAnchor.ConstraintEqualTo(pillHeight),
executeBtn.WidthAnchor.ConstraintEqualTo(executeButtonWidth),
pillContainer.LeadingAnchor.ConstraintEqualTo(accessoryView.LeadingAnchor, padding),
pillContainer.TrailingAnchor.ConstraintEqualTo(executeBtn.LeadingAnchor, -padding),
pillContainer.TrailingAnchor.ConstraintEqualTo(accessoryView.TrailingAnchor, -padding),
pillContainer.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor),
pillContainer.HeightAnchor.ConstraintEqualTo(pillHeight),
});
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();
});
platformView.Changed += (s, e) =>
{
AutoCapitalizeKeywords(platformView, _keyWords);
HighlightWords(platformView, _keyWords);
};
}
private static void HighlightWords(UITextView platformView, IEnumerable<string> wordsToHighlight)
{
var text = PreprocessText(platformView.Text ?? string.Empty);
var attributedText = new NSMutableAttributedString(text);
attributedText.AddAttribute(UIStringAttributeKey.ForegroundColor,
UIColor.Label,
new NSRange(0, text.Length));
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)
{
text = text.Replace("\u2018", "'");

View File

@@ -24,7 +24,7 @@
</imageView>
</subviews>
<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>
<constraint firstItem="198" firstAttribute="centerX" secondItem="1038" secondAttribute="centerX" id="201"/>
<constraint firstItem="1038" firstAttribute="centerY" secondItem="198" secondAttribute="centerY" id="203"/>

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
<?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">
<Label FontAttributes="Bold" TextColor="{DynamicResource Accent}" Text="{Binding Name}">
<Label.GestureRecognizers>

View File

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

View File

@@ -2,12 +2,15 @@
<ContentView 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"
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
x:DataType="models:QueryResult"
x:Class="Xamarin.Neo4j.Controls.QueryResultView"
x:Name="self">
<Grid VerticalOptions="Start">
<!-- Graph WebView -->
<WebView x:Name="graphView"
<WebView x:Name="graphView" x:DataType="{x:Null}"
HorizontalOptions="FillAndExpand"
IsVisible="{Binding CanDisplayGraph}"
HeightRequest="{Binding GraphViewHeight, Source={x:Reference self}}" />

View File

@@ -4,6 +4,7 @@ using System.Reflection;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Xaml;
using Microsoft.Maui.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui;
using Xamarin.Neo4j.Managers;
@@ -42,8 +43,28 @@ namespace Xamarin.Neo4j.Controls
graphView.Source = new HtmlWebViewSource { Html = QueryResult?.NeovisHtml };
}
private double _originalHeight;
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;
e.Cancel = true;
@@ -110,6 +131,13 @@ namespace Xamarin.Neo4j.Controls
html = html.Replace("{{edges}}", edgesJson);
html = html.Replace("{{backgroundColor}}", isDark ? "#292C31" : "#FFFFFF");
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;
}

View File

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

View File

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

View File

@@ -18,5 +18,7 @@ namespace Xamarin.Neo4j.Models
public bool Default { get; set; }
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 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()
{
var fullHost = Scheme + Host;

View File

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

View File

@@ -3,10 +3,26 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
x:DataType="vm:ConnectionsViewModel"
x:Class="Xamarin.Neo4j.Pages.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>
<ToolbarItem Text="Settings" Order="Secondary" Command="{Binding Commands[OpenSettings]}" />
@@ -14,19 +30,98 @@ x:Class="Xamarin.Neo4j.Pages.ConnectionsPage"
<ContentPage.Content>
<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>
<!-- Scrollable connection cards -->
<ScrollView IsVisible="{Binding HasItems}">
<StackLayout BindableLayout.ItemsSource="{Binding ConnectionStrings}"
Spacing="0" Padding="0,16,0,80">
<BindableLayout.ItemTemplate>
<DataTemplate x:DataType="models:Neo4jConnectionString">
<Border Margin="16,0,16,8"
BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1"
StrokeShape="RoundRectangle 10">
<Grid ColumnDefinitions="Auto,*,Auto" Padding="14,12">
<Grid.GestureRecognizers>
<TapGestureRecognizer x:DataType="{x:Null}"
Command="{Binding BindingContext.Commands[OpenConnection], Source={x:Reference connectionsPage}}"
CommandParameter="{Binding .}" />
</Grid.GestureRecognizers>
<!-- Database icon -->
<Border Grid.Column="0"
WidthRequest="40" HeightRequest="40"
StrokeThickness="0"
StrokeShape="RoundRectangle 20"
BackgroundColor="{DynamicResource QueryActionBar}"
VerticalOptions="Center">
<Label Text="{x:Static fonts:FontAwesomeSolid.Database}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="16"
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}"
@@ -50,8 +145,8 @@ x:Class="Xamarin.Neo4j.Pages.ConnectionsPage"
FontSize="20"
WidthRequest="56" HeightRequest="56"
CornerRadius="28"
BackgroundColor="{DynamicResource Accent}"
TextColor="White"
BackgroundColor="{DynamicResource FabBackgroundColor}"
TextColor="{DynamicResource FabTextColor}"
VerticalOptions="End" HorizontalOptions="End"
Margin="0,0,16,16"
Command="{Binding Commands[AddConnection]}" />

View File

@@ -42,9 +42,7 @@ namespace Xamarin.Neo4j.Pages
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"
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"
x:Class="Xamarin.Neo4j.Pages.GraphPage">
<ContentPage.Content>

View File

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

View File

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

View File

@@ -4,6 +4,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
xmlns: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:Name="sessionPage">
<NavigationPage.TitleView>
@@ -21,7 +24,7 @@
</Label.FormattedText>
</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>
</NavigationPage.TitleView>
@@ -30,7 +33,8 @@
<ToolbarItem.IconImageSource>
<FontImageSource
FontFamily="{StaticResource FontAwesomeSolid}"
Glyph="{x:Static fonts:FontAwesomeSolid.Play}" Size="20" />
Glyph="{x:Static fonts:FontAwesomeSolid.Play}" Size="20"
Color="#007AFF" />
</ToolbarItem.IconImageSource>
</ToolbarItem>
</ContentPage.ToolbarItems>
@@ -81,11 +85,11 @@
IsVisible="{Binding HasSavedQueries}"
Spacing="0">
<BindableLayout.ItemTemplate>
<DataTemplate>
<DataTemplate x:DataType="models:Query">
<Grid ColumnDefinitions="*,Auto" Padding="0,10">
<StackLayout Grid.Column="0" Spacing="2">
<StackLayout.GestureRecognizers>
<TapGestureRecognizer
<TapGestureRecognizer x:DataType="{x:Null}"
Command="{Binding BindingContext.Commands[LoadQuery], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" />
</StackLayout.GestureRecognizers>
@@ -96,7 +100,7 @@
MaxLines="1"
LineBreakMode="TailTruncation" />
</StackLayout>
<Button Grid.Column="1"
<Button Grid.Column="1" x:DataType="{x:Null}"
Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="16"
@@ -143,74 +147,74 @@
<StackLayout BindableLayout.ItemsSource="{Binding QueryResults}" Spacing="0">
<BindableLayout.ItemTemplate>
<DataTemplate>
<Border Margin="12,0,12,8"
<DataTemplate x:DataType="models:QueryResult">
<Border Margin="12,0,12,6"
BackgroundColor="{DynamicResource Extreme}"
Stroke="{DynamicResource QueryTextBorder}"
StrokeThickness="1"
StrokeShape="RoundRectangle 4">
<StackLayout Spacing="0">
<!-- Action row -->
<Grid ColumnDefinitions="*,*,*,*,Auto" Padding="4,2">
<Grid ColumnDefinitions="*,*,*,*,Auto" Padding="4,0" x:DataType="{x:Null}">
<Button Grid.Column="0"
Text="{x:Static fonts:FontAwesomeSolid.Star}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
BackgroundColor="Transparent"
TextColor="{DynamicResource SecondaryTextColor}"
HeightRequest="40"
HeightRequest="32"
Command="{Binding BindingContext.Commands[SaveQuery], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" />
<Button Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.Expand}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
BackgroundColor="Transparent"
IsEnabled="{Binding CanDisplayGraph}"
TextColor="{DynamicResource SecondaryTextColor}"
HeightRequest="40"
HeightRequest="32"
Command="{Binding BindingContext.Commands[OpenGraph], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" />
<Button Grid.Column="2"
Text="{x:Static fonts:FontAwesomeSolid.Code}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
BackgroundColor="Transparent"
IsEnabled="{Binding Success}"
TextColor="{DynamicResource SecondaryTextColor}"
HeightRequest="40"
HeightRequest="32"
Command="{Binding BindingContext.Commands[OpenTable], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" />
<Button Grid.Column="3"
Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
BackgroundColor="Transparent"
TextColor="{DynamicResource SecondaryTextColor}"
HeightRequest="40"
HeightRequest="32"
Command="{Binding BindingContext.Commands[DeleteResult], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" />
<Button Grid.Column="4"
Text="{x:Static fonts:FontAwesomeSolid.ChevronUp}"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="12"
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="10"
BackgroundColor="Transparent"
TextColor="{DynamicResource SecondaryTextColor}"
WidthRequest="40" HeightRequest="40"
WidthRequest="32" HeightRequest="32"
Clicked="ToggleResultCollapse" />
</Grid>
<!-- Query label -->
<Label Text="{Binding DisplayQuery}"
Padding="12,0,12,8"
FontSize="12"
Padding="12,0,12,4"
FontSize="11"
TextColor="{DynamicResource SecondaryTextColor}"
LineBreakMode="TailTruncation"
MaxLines="2">
MaxLines="1">
<Label.GestureRecognizers>
<TapGestureRecognizer
<TapGestureRecognizer x:DataType="{x:Null}"
Command="{Binding BindingContext.Commands[LoadResultQuery], Source={x:Reference sessionPage}}"
CommandParameter="{Binding .}" />
</Label.GestureRecognizers>
</Label>
<!-- Result content -->
<controls:QueryResultView
<controls:QueryResultView x:DataType="{x:Null}"
GraphViewHeight="{Binding BindingContext.GraphViewHeight, Source={x:Reference sessionPage}}" />
</StackLayout>
</Border>
@@ -219,8 +223,12 @@
</StackLayout>
</StackLayout>
<!-- Empty hint (no results yet) -->
<StackLayout IsVisible="{Binding IsEmpty}" HorizontalOptions="Center" Spacing="12" Padding="32,24">
</StackLayout>
</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"
@@ -237,9 +245,6 @@
HorizontalOptions="Center" />
</StackLayout>
</StackLayout>
</ScrollView>
</Grid>
</ContentPage.Content>
</ContentPage>

View File

@@ -3,6 +3,8 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
x:DataType="vm:SettingsViewModel"
Title="Settings"
x:Class="Xamarin.Neo4j.Pages.SettingsPage">
<ContentPage.IconImageSource>
@@ -13,14 +15,12 @@
</ContentPage.IconImageSource>
<ContentPage.Content>
<Grid RowDefinitions="*,Auto">
<ScrollView Grid.Row="0">
<ScrollView>
<StackLayout Padding="16" Spacing="24">
<!-- Support -->
<!-- Editor -->
<StackLayout Spacing="8">
<Label Text="SUPPORT"
<Label Text="EDITOR"
FontSize="11" FontAttributes="Bold"
CharacterSpacing="2"
TextColor="{DynamicResource SecondaryTextColor}"
@@ -30,33 +30,21 @@
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"
<Grid ColumnDefinitions="*,Auto" Padding="16,10">
<Label Grid.Column="0" Text="Auto-capitalize keywords"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
<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,14">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Commands[RateApp]}" />
</Grid.GestureRecognizers>
<Label Grid.Column="0" Text="Rate PocketGraph"
<Grid ColumnDefinitions="*,Auto" Padding="16,10">
<Label Grid.Column="0" Text="Auto-zoom on node tap"
TextColor="{DynamicResource PrimaryTextColor}"
VerticalOptions="Center" />
<Label Grid.Column="1"
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
FontFamily="{StaticResource FontAwesomeSolid}"
FontSize="12"
TextColor="{DynamicResource SecondaryTextColor}"
<Switch Grid.Column="1" OnColor="#007AFF"
IsToggled="{Binding AutoZoom}"
VerticalOptions="Center" />
</Grid>
</StackLayout>
@@ -108,6 +96,51 @@
</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"
@@ -147,23 +180,23 @@
</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>
</Grid>
</ContentPage.Content>
</ContentPage>

View File

@@ -76,12 +76,18 @@ namespace Xamarin.Neo4j.Pages
var isDark = Application.Current.RequestedTheme == AppTheme.Dark;
html = html.Replace("{{json}}", json);
html = html.Replace("{{backgroundColor}}", isDark ? "#1e1e1e" : "#ffffff");
html = html.Replace("{{textColor}}", isDark ? "#d4d4d4" : "#1a1a1a");
html = html.Replace("{{toolbarBg}}", isDark ? "#252526" : "#f5f5f5");
html = html.Replace("{{inputBg}}", isDark ? "#3c3c3c" : "#ffffff");
html = html.Replace("{{borderColor}}", isDark ? "#454545" : "#cccccc");
html = html.Replace("{{mutedColor}}", isDark ? "#808080" : "#8a8a8a");
html = html.Replace("{{backgroundColor}}", isDark ? "#0c0c0c" : "#f5f5f5");
html = html.Replace("{{textColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
html = html.Replace("{{toolbarBg}}", isDark ? "#141414" : "#ffffff");
html = html.Replace("{{inputBg}}", isDark ? "#1a1a1a" : "#ffffff");
html = html.Replace("{{borderColor}}", isDark ? "#2a2a2a" : "#e0e0e0");
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;
}
}

View File

@@ -17,6 +17,9 @@
<Color x:Key="QueryTextBorder">#2a2a2a</Color>
<Color x:Key="QueryActionBar">#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">
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
@@ -28,7 +31,7 @@
</Style>
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="#141414"/>
<Setter Property="BarBackgroundColor" Value="{StaticResource NavigationBarColor}"/>
<Setter Property="BarTextColor" Value="#e2e2e2" />
</Style>

View File

@@ -17,6 +17,9 @@
<Color x:Key="QueryTextBorder">#e0e0e0</Color>
<Color x:Key="QueryActionBar">#f5f5f5</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">
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
@@ -37,7 +40,7 @@
</Style>
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="#0c0c0c"/>
<Setter Property="BarBackgroundColor" Value="{StaticResource NavigationBarColor}"/>
<Setter Property="BarTextColor" Value="#e2e2e2" />
</Style>

View File

@@ -26,7 +26,7 @@ namespace Xamarin.Neo4j.ViewModels
{
private Guid? _id;
private string _scheme, _host, _username, _password;
private string _name, _scheme, _host, _username, _password;
private readonly Neo4jService _neo4jService;
@@ -48,27 +48,20 @@ namespace Xamarin.Neo4j.ViewModels
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 () =>
{
var connectionString = BuildConnectionString();
if (string.IsNullOrWhiteSpace(connectionString.Name))
connectionString.Name = connectionString.Host;
if (_id.HasValue)
await ConnectionStringManager.UpdateConnectionString(_id.Value, connectionString);
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 Navigation.PopAsync();
}));
@@ -83,7 +76,7 @@ namespace Xamarin.Neo4j.ViewModels
await Navigation.PushAsync(new SessionPage(connectionString));
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;
Name = neo4JConnectionString.Name;
Scheme = neo4JConnectionString.Scheme;
Host = neo4JConnectionString.Host;
Username = neo4JConnectionString.Username;
@@ -114,6 +108,7 @@ namespace Xamarin.Neo4j.ViewModels
return new Neo4jConnectionString
{
Id = Guid.NewGuid(),
Name = Name,
Scheme = Scheme,
Host = Host,
Username = Username,
@@ -123,6 +118,16 @@ namespace Xamarin.Neo4j.ViewModels
#region Bindable Properties
public string Name
{
get => _name;
set
{
_name = value;
OnPropertyChanged(nameof(Name));
}
}
public string Scheme
{
get => _scheme;

View File

@@ -50,6 +50,12 @@ namespace Xamarin.Neo4j.ViewModels
LoadConnectionStrings();
}));
Commands.Add("OpenConnection", new Command((o) =>
{
if (o is Neo4jConnectionString cs)
OpenSession(cs);
}));
Commands.Add("OpenSettings", new Command(async () =>
{
await Navigation.PushAsync(new SettingsPage());
@@ -87,6 +93,7 @@ namespace Xamarin.Neo4j.ViewModels
OnPropertyChanged(nameof(ConnectionStrings));
OnPropertyChanged(nameof(IsEmpty));
OnPropertyChanged(nameof(HasItems));
OnPropertyChanged(nameof(ConnectionCountLabel));
}
}
@@ -94,6 +101,20 @@ namespace Xamarin.Neo4j.ViewModels
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
}
}

View File

@@ -42,7 +42,7 @@ namespace Xamarin.Neo4j.ViewModels
{
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;
}

View File

@@ -82,7 +82,7 @@ namespace Xamarin.Neo4j.ViewModels
if (!(o is Query query))
return;
var confirmed = await Application.Current.MainPage.DisplayAlert(
var confirmed = await Application.Current.Windows[0].Page.DisplayAlertAsync(
"Delete Query",
$"Delete \"{query.Name}\"?",
"Delete", "Cancel");
@@ -115,7 +115,7 @@ namespace Xamarin.Neo4j.ViewModels
Commands.Add("SaveQuery", new Command(async (o) =>
{
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?");
if (!string.IsNullOrWhiteSpace(name))
{
@@ -163,7 +163,7 @@ namespace Xamarin.Neo4j.ViewModels
if (!isConnected)
{
await Application.Current.MainPage.DisplayAlert("", message, "OK");
await Application.Current.Windows[0].Page.DisplayAlertAsync("", message, "OK");
return;
}
@@ -276,7 +276,7 @@ namespace Xamarin.Neo4j.ViewModels
get
{
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)
{
await Application.Current.MainPage.DisplayAlert(
await Application.Current.Windows[0].Page.DisplayAlertAsync(
"No Email App", "No email client is configured on this device.", "OK");
return;
}
@@ -96,7 +96,7 @@ namespace Xamarin.Neo4j.ViewModels
Commands.Add("ClearConnections", new Command(async () =>
{
var clear = await Application.Current.MainPage.DisplayAlert(
var clear = await Application.Current.Windows[0].Page.DisplayAlertAsync(
"Clear Connections",
"Are you sure you want to remove all saved connections?",
"Clear", "Cancel");
@@ -109,7 +109,7 @@ namespace Xamarin.Neo4j.ViewModels
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",
"Are you sure you want to remove all saved queries?",
"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
}
}

View File

@@ -3,9 +3,9 @@
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<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}
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 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}
@@ -14,8 +14,8 @@ body{font-family:'Courier New',monospace;font-size:13px;background:var(--bg);col
.tbtn:active{opacity:.7}
#status{font-size:11px;color:var(--muted);padding:0 2px;min-height:15px}
#output{padding:10px}
.s{color:#4CAF50}.n{color:#64B5F6}.b{color:#FFB74D}.nl{color:#90A4AE}.k{color:#EF9A9A}.e{color:#EF5350}
.br{color:var(--fg);opacity:.7}.cm{color:var(--fg);opacity:.4}
.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:.85}.cm{color:var(--fg);opacity:.5}
/* Block-based tree nodes */
.node,.knode{display:block}
.nhd{display:block;cursor:default}
@@ -154,13 +154,19 @@ function count(v){
function setTheme(isDark){
const r=document.documentElement.style;
r.setProperty('--bg',isDark?'#1e1e1e':'#ffffff');
r.setProperty('--fg',isDark?'#d4d4d4':'#1a1a1a');
r.setProperty('--toolbar',isDark?'#252526':'#f5f5f5');
r.setProperty('--input',isDark?'#3c3c3c':'#ffffff');
r.setProperty('--border',isDark?'#454545':'#cccccc');
r.setProperty('--muted',isDark?'#808080':'#8a8a8a');
document.body.style.background=isDark?'#1e1e1e':'#ffffff';
r.setProperty('--bg',isDark?'#0c0c0c':'#f5f5f5');
r.setProperty('--fg',isDark?'#e2e2e2':'#0c0c0c');
r.setProperty('--toolbar',isDark?'#141414':'#ffffff');
r.setProperty('--input',isDark?'#1a1a1a':'#ffffff');
r.setProperty('--border',isDark?'#2a2a2a':'#e0e0e0');
r.setProperty('--muted',isDark?'#868686':'#868a92');
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()});

View File

@@ -2,42 +2,281 @@
<html lang="en-US">
<head>
<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>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: {{backgroundColor}}; }
canvas { display: block; position: absolute; top: 0; left: 0; }
#popup {
/* ── Slide-up detail panel ── */
#panel-backdrop {
display: none;
position: fixed;
bottom: 24px;
left: 16px;
right: 16px;
background: rgba(20, 20, 30, 0.95);
color: #fff;
top: 0;
left: 0;
right: 0;
bottom: 55%;
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;
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-size: 13px;
line-height: 1.5;
max-height: 40%;
overflow-y: auto;
z-index: 100;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
z-index: 200;
pointer-events: none;
}
#popup .lbl {
font-size: 15px;
font-weight: 600;
margin-bottom: 6px;
color: #7EC8E3;
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#popup .prop { color: #ccc; }
#popup .prop b { color: #fff; }
</style>
</head>
<body>
<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>
(function () {
@@ -51,7 +290,12 @@
var canvas = document.getElementById('c');
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;
// ---- node/edge state ----
@@ -134,8 +378,6 @@
edges.forEach(function (e) {
a = nodeById[e.from]; b = nodeById[e.to];
if (!a || !b) return;
// leaf-to-hub: short spring (leaf stays close as petal)
// hub-to-hub: long spring (clusters separate)
var len;
if (a.isHub && b.isHub) {
len = HUB_SPRING;
@@ -190,6 +432,35 @@
// ---- camera (pan + zoom, initial zoom based on node count) ----
var initialZoom = N <= 10 ? 1 : Math.max(0.05, 1 / Math.sqrt(N / 10));
var panX = 0, panY = 0, zoom = initialZoom;
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
function toWorld(sx, sy) {
@@ -257,7 +528,7 @@
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.font = 'bold 11px -apple-system, sans-serif';
ctx.textAlign = 'center';
@@ -267,57 +538,396 @@
ctx.restore();
}
// ---- popup ----
function buildPropsHtml(title) {
var html = '';
if (title) {
// ---- panel ----
var panelOpen = false;
var panelViewState = 'props'; // 'props', 'json', 'relations'
var panelCurrentItem = null;
var panelCurrentType = null; // 'node' or 'edge'
// SVG icon paths
var ICONS = {
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>',
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) {
html += '<div class="prop"><b>' + escHtml(pair.slice(0, idx)) + ':</b> ' + escHtml(pair.slice(idx + 2)) + '</div>';
} else {
html += '<div class="prop">' + escHtml(pair) + '</div>';
props.push({ key: pair.slice(0, idx), value: pair.slice(idx + 2) });
} else if (pair.trim()) {
props.push({ key: '', value: pair });
}
});
} else {
html += '<div class="prop">No properties</div>';
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 {
// 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;
}
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;
selectedEdge = null;
var html = '<div class="lbl">' + escHtml(n.label) + '</div>';
html += buildPropsHtml(n.title);
popup.innerHTML = html;
popup.style.display = 'block';
panelCurrentItem = n;
panelCurrentType = 'node';
panelViewState = 'props';
// 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();
}
function showEdgePopup(e) {
function showEdgePanel(e) {
selectedEdge = e;
selected = null;
var html = '<div class="lbl" style="color:#E8A838">' + escHtml(e.label || 'Relationship') + '</div>';
html += buildPropsHtml(e.title);
popup.innerHTML = html;
popup.style.display = 'block';
panelCurrentItem = e;
panelCurrentType = 'edge';
panelViewState = 'props';
// 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();
}
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;
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();
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) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ---- expand / collapse ----
var lastTapTime = 0, lastTapNode = null;
var lastTapTime = 0, lastTapNode = null, singleTapTimer = null;
var expandedBy = {}; // nodeId -> [childIds added by expanding that node]
// initial nodes are never collapsible
@@ -346,19 +956,13 @@
}
function expandNode(n) {
var f = document.createElement('iframe');
f.style.display = 'none';
f.src = 'app://expand?nodeId=' + n.id;
document.body.appendChild(f);
setTimeout(function () { document.body.removeChild(f); }, 200);
notifyApp('app://expand?nodeId=' + n.id);
}
function collapseNode(n) {
var childIds = expandedBy[n.id];
if (!childIds) return;
// only remove children that aren't initial nodes and aren't
// also owned by another still-expanded node
var otherOwned = {};
Object.keys(expandedBy).forEach(function (ownerId) {
if (ownerId == n.id) return;
@@ -370,7 +974,6 @@
if (!initialNodeIds[cid] && !otherOwned[cid]) toRemove[cid] = true;
});
// also collapse any nodes that were expanded from nodes we're about to remove
Object.keys(toRemove).forEach(function (rid) {
if (expandedBy[rid]) {
expandedBy[rid].forEach(function (cid) {
@@ -380,19 +983,16 @@
}
});
// remove edges connected to removed nodes
edges = edges.filter(function (e) {
return !toRemove[e.from] && !toRemove[e.to];
});
// remove nodes
nodes = nodes.filter(function (nd) { return !toRemove[nd.id]; });
Object.keys(toRemove).forEach(function (rid) { delete nodeById[rid]; });
delete expandedBy[n.id];
// hide popup if the selected node was removed
if (selected && toRemove[selected.id]) hidePopup();
if (selected && toRemove[selected.id]) hidePanel();
recomputeDegrees();
resumeLoop();
@@ -407,7 +1007,7 @@
newNodes.forEach(function (d) {
if (nodeById[d.id]) {
addedIds.push(d.id); // track even existing ones as children
addedIds.push(d.id);
return;
}
var angle = Math.random() * 2 * Math.PI;
@@ -429,13 +1029,22 @@
});
if (exists) 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;
recomputeDegrees();
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) ----
@@ -444,21 +1053,16 @@
function notifyInteraction() {
if (interacted) return;
interacted = true;
var f = document.createElement('iframe');
f.style.display = 'none';
f.src = 'app://interaction';
document.body.appendChild(f);
setTimeout(function () { document.body.removeChild(f); }, 200);
notifyApp('app://interaction');
}
function edgeAt(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;
edges.forEach(function (e) {
var a = nodeById[e.from], b = nodeById[e.to];
if (!a || !b) return;
// point-to-segment distance
var dx = b.x - a.x, dy = b.y - a.y;
var lenSq = dx * dx + dy * dy;
if (lenSq === 0) return;
@@ -487,6 +1091,7 @@
function handleStart(e) {
if (e.touches) e.preventDefault();
if (e.touches && e.touches.length >= 2) return; // pinch handled separately
notifyInteraction();
var p = coordFromEvent(e);
ptrStartX = ptrEndX = p.x;
@@ -524,23 +1129,30 @@
if (!moved && dt < 300) {
var hit = nodeAt(ptrStartX, ptrStartY);
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) {
lastTapNode = null; lastTapTime = 0;
if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
toggleExpandCollapse(hit);
} else if (hit) {
lastTapNode = hit; lastTapTime = now;
if (hit === selected) hidePopup();
else showPopup(hit);
// Delay panel open to distinguish from double-tap
var tappedNode = hit;
if (singleTapTimer) clearTimeout(singleTapTimer);
singleTapTimer = setTimeout(function () {
singleTapTimer = null;
if (tappedNode === selected && panelOpen) hidePanel();
else showNodePanel(tappedNode);
}, 350);
} else {
lastTapNode = null; lastTapTime = 0;
// no node hit — check if an edge was tapped
if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
var hitEdge = edgeAt(ptrStartX, ptrStartY);
if (hitEdge) {
if (hitEdge === selectedEdge) hidePopup();
else showEdgePopup(hitEdge);
if (hitEdge === selectedEdge && panelOpen) hidePanel();
else showEdgePanel(hitEdge);
} else {
hidePopup();
if (panelOpen) hidePanel();
}
}
}
@@ -566,10 +1178,6 @@
canvas.addEventListener('mousemove', handleMove);
canvas.addEventListener('mouseup', handleEnd);
// dismiss popup when tapping/clicking it
popup.addEventListener('touchend', function (e) { e.stopPropagation(); hidePopup(); });
popup.addEventListener('click', function (e) { e.stopPropagation(); hidePopup(); });
// ---- zoom: scroll wheel ----
canvas.addEventListener('wheel', function (e) {
e.preventDefault();
@@ -577,7 +1185,6 @@
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
var newZoom = Math.min(Math.max(zoom * factor, 0.1), 10);
// zoom toward mouse position
panX = mx - (mx - panX) * (newZoom / zoom);
panY = my - (my - panY) * (newZoom / zoom);
zoom = newZoom;
@@ -638,14 +1245,12 @@
function init() {
resize();
// WebView may not have layout yet — retry until we get real dimensions
if ((W <= 1 || H <= 1) && initRetries < 20) {
initRetries++;
setTimeout(init, 50);
return;
}
placeInitial();
// center the camera on the graph with the pre-computed zoom
panX = W / 2 - (W / 2) * zoom;
panY = H / 2 - (H / 2) * zoom;
loop();
@@ -656,7 +1261,6 @@
draw();
});
// Use rAF to ensure the browser has laid out the canvas before we read its size
requestAnimationFrame(function () {
requestAnimationFrame(init);
});

View File

@@ -5,6 +5,15 @@
<UseMaui>true</UseMaui>
<LangVersion>latest</LangVersion>
<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 Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -15,6 +24,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.20" />
<PackageReference Include="Neo4jClient" Version="4.1.18" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</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"]
}