mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): QR/code pairing flow with security hardening (#317)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
"jdenticon": "^3.3.0",
|
||||
"livekit-client": "^2.18.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.0",
|
||||
"react-diff-view": "^3.3.2",
|
||||
"react-dom": "^19.1.0",
|
||||
|
||||
Generated
+12
@@ -80,6 +80,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.5)
|
||||
qrcode.react:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(react@19.2.5)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.5
|
||||
@@ -1994,6 +1997,11 @@ packages:
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
qrcode.react@4.2.0:
|
||||
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -4235,6 +4243,10 @@ snapshots:
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
qrcode.react@4.2.0(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
react-diff-view@3.3.2(react@19.2.5):
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { Check, Copy, Loader2, Smartphone, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { useMintTokenMutation } from "@/features/tokens/hooks";
|
||||
import { getRelayHttpUrl } from "@/shared/api/tauri";
|
||||
import type { TokenScope } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
|
||||
const MOBILE_SCOPES: TokenScope[] = [
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"users:read",
|
||||
"files:read",
|
||||
];
|
||||
const EXPIRES_IN_DAYS = 90;
|
||||
|
||||
function toBase64Url(str: string): string {
|
||||
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
type PairingPayload = {
|
||||
relayUrl: string;
|
||||
token: string;
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
function PairingDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentPubkey,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentPubkey: string;
|
||||
}) {
|
||||
const mintMutation = useMintTokenMutation();
|
||||
const mintRef = useRef(mintMutation.mutateAsync);
|
||||
mintRef.current = mintMutation.mutateAsync;
|
||||
|
||||
const [pairingUri, setPairingUri] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const generate = useCallback(async (pubkey: string) => {
|
||||
const [tokenResult, relayUrl] = await Promise.all([
|
||||
mintRef.current({
|
||||
name: `mobile-${Date.now()}`,
|
||||
scopes: [...MOBILE_SCOPES],
|
||||
expiresInDays: EXPIRES_IN_DAYS,
|
||||
}),
|
||||
getRelayHttpUrl(),
|
||||
]);
|
||||
|
||||
const payload: PairingPayload = {
|
||||
relayUrl,
|
||||
token: tokenResult.token,
|
||||
pubkey,
|
||||
};
|
||||
return `sprout://${toBase64Url(JSON.stringify(payload))}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setPairingUri(null);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
generate(currentPubkey).then(
|
||||
(uri) => {
|
||||
if (!cancelled) setPairingUri(uri);
|
||||
},
|
||||
(err) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to generate pairing code",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, currentPubkey, generate]);
|
||||
|
||||
async function handleCopy() {
|
||||
if (!pairingUri) return;
|
||||
await navigator.clipboard.writeText(pairingUri);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="max-w-md overflow-hidden p-0"
|
||||
data-testid="mobile-pairing-dialog"
|
||||
>
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Pair Mobile Device</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan this QR code with the Sprout mobile app, or copy the pairing
|
||||
code for manual setup.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{error ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : !pairingUri ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generating pairing code…
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center rounded-lg border border-border/70 bg-white p-4">
|
||||
<QRCodeSVG
|
||||
data-testid="mobile-pairing-qr"
|
||||
level="M"
|
||||
size={240}
|
||||
value={pairingUri}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Pairing code
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-xs">
|
||||
{pairingUri}
|
||||
</code>
|
||||
<Button
|
||||
data-testid="copy-pairing-code"
|
||||
onClick={handleCopy}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This token expires in {EXPIRES_IN_DAYS} days. You can revoke
|
||||
it from the Tokens settings at any time.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border/60 bg-background/95 px-6 py-4">
|
||||
<Button
|
||||
data-testid="mobile-pairing-done"
|
||||
onClick={() => onOpenChange(false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobilePairingCard({
|
||||
currentPubkey,
|
||||
}: {
|
||||
currentPubkey?: string;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="min-w-0 space-y-3" data-testid="settings-mobile">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Mobile</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect the Sprout mobile app to this relay by scanning a QR code or
|
||||
pasting a pairing code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border/80 bg-muted/25 px-4 py-3">
|
||||
<Smartphone className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Pair Mobile Device</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generate a one-time pairing code for the mobile app
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="pair-mobile-button"
|
||||
disabled={!currentPubkey}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
Pair
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentPubkey && (
|
||||
<PairingDialog
|
||||
currentPubkey={currentPubkey}
|
||||
onOpenChange={setDialogOpen}
|
||||
open={dialogOpen}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MonitorCog,
|
||||
Moon,
|
||||
Search,
|
||||
Smartphone,
|
||||
Stethoscope,
|
||||
Sun,
|
||||
UserRound,
|
||||
@@ -20,6 +21,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
|
||||
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
|
||||
import { MobilePairingCard } from "./MobilePairingCard";
|
||||
import { NotificationSettingsCard } from "./NotificationSettingsCard";
|
||||
import { ProfileSettingsCard } from "./ProfileSettingsCard";
|
||||
|
||||
@@ -28,6 +30,7 @@ export type SettingsSection =
|
||||
| "notifications"
|
||||
| "appearance"
|
||||
| "tokens"
|
||||
| "mobile"
|
||||
| "doctor";
|
||||
|
||||
export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
|
||||
@@ -72,6 +75,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
label: "Tokens",
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
value: "mobile",
|
||||
label: "Mobile",
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
value: "doctor",
|
||||
label: "Doctor",
|
||||
@@ -228,6 +236,8 @@ export function renderSettingsSection(
|
||||
return <ThemeSettingsCard />;
|
||||
case "tokens":
|
||||
return <TokenSettingsCard currentPubkey={props.currentPubkey} />;
|
||||
case "mobile":
|
||||
return <MobilePairingCard currentPubkey={props.currentPubkey} />;
|
||||
case "doctor":
|
||||
return <DoctorSettingsPanel />;
|
||||
default: {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
platform :ios, '16.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- GoogleMLKit/BarcodeScanning (7.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 6.0.0)
|
||||
- GoogleMLKit/MLKitCore (7.0.0):
|
||||
- MLKitCommon (~> 12.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
- GoogleToolboxForMac/Logger (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- MLImage (1.0.0-beta6)
|
||||
- MLKitBarcodeScanning (6.0.0):
|
||||
- MLKitCommon (~> 12.0)
|
||||
- MLKitVision (~> 8.0)
|
||||
- MLKitCommon (12.0.0):
|
||||
- GoogleDataTransport (~> 10.0)
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleUtilities/Logger (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitVision (8.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLImage (= 1.0.0-beta6)
|
||||
- MLKitCommon (~> 12.0)
|
||||
- mobile_scanner (6.0.2):
|
||||
- Flutter
|
||||
- GoogleMLKit/BarcodeScanning (~> 7.0.0)
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- PromisesObjC (2.4.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUtilities
|
||||
- GTMSessionFetcher
|
||||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
|
||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
||||
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
||||
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
||||
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
|
||||
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
|
||||
PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -9,7 +9,9 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
@@ -43,13 +45,20 @@
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
529B9B84AD8748153EB60BCF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8EB101B7046447A0502FE6C9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -57,13 +66,23 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
3C28C6B702C81085E6F96F2A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -78,6 +97,20 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
746EA2D40EB8F35E67C48311 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */,
|
||||
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */,
|
||||
8EB101B7046447A0502FE6C9 /* Pods-Runner.profile.xcconfig */,
|
||||
42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */,
|
||||
7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */,
|
||||
529B9B84AD8748153EB60BCF /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -96,6 +129,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
746EA2D40EB8F35E67C48311 /* Pods */,
|
||||
D12A84F9A6072498822784CC /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -124,6 +159,15 @@
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D12A84F9A6072498822784CC /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */,
|
||||
CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -131,8 +175,10 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
29C475F38B9F5B6D27BD900B /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
3C28C6B702C81085E6F96F2A /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -148,12 +194,15 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
1D327B920BBA7EF4545E2A92 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */,
|
||||
0CF701FE301CC7B2D25E86E6 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -225,6 +274,67 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
0CF701FE301CC7B2D25E86E6 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
1D327B920BBA7EF4545E2A92 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
29C475F38B9F5B6D27BD900B /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -256,6 +366,23 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -350,7 +477,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -383,6 +510,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -400,6 +528,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -415,6 +544,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 529B9B84AD8748153EB60BCF /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -477,7 +607,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -528,7 +658,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
+66
-1
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import 'features/home/home_page.dart';
|
||||
import 'features/pairing/pairing_page.dart';
|
||||
import 'shared/auth/auth.dart';
|
||||
import 'shared/theme/theme.dart';
|
||||
|
||||
class App extends HookConsumerWidget {
|
||||
@@ -10,13 +13,75 @@ class App extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeProvider);
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Sprout',
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: themeMode,
|
||||
home: const HomePage(),
|
||||
home: authState.when(
|
||||
loading: () => const _SplashScreen(),
|
||||
error: (_, _) => const PairingPage(),
|
||||
data: (state) => switch (state.status) {
|
||||
AuthStatus.authenticated => const HomePage(),
|
||||
AuthStatus.offline => const _OfflineScreen(),
|
||||
_ => const PairingPage(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SplashScreen extends StatelessWidget {
|
||||
const _SplashScreen();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
}
|
||||
|
||||
class _OfflineScreen extends ConsumerWidget {
|
||||
const _OfflineScreen();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.wifiOff,
|
||||
size: 48,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
'Unable to reach relay',
|
||||
style: context.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'Your pairing is saved — check your connection and try again.',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
FilledButton.icon(
|
||||
onPressed: () => ref.read(authProvider.notifier).retry(),
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import 'pairing_provider.dart';
|
||||
|
||||
class PairingPage extends HookConsumerWidget {
|
||||
const PairingPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pairingState = ref.watch(pairingProvider);
|
||||
final codeController = useTextEditingController();
|
||||
final isConnecting = pairingState.status == PairingStatus.connecting;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// Branding
|
||||
Icon(LucideIcons.sprout, size: 64, color: context.colors.primary),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text('Welcome to Sprout', style: context.textTheme.headlineSmall),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'Scan the QR code from your desktop app\nor paste a pairing code to connect.',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Scan QR button
|
||||
FilledButton.icon(
|
||||
onPressed: isConnecting
|
||||
? null
|
||||
: () => _openScanner(context, ref),
|
||||
icon: const Icon(LucideIcons.scanLine),
|
||||
label: const Text('Scan QR Code'),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
// Divider
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
),
|
||||
child: Text(
|
||||
'or paste pairing code',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
// Paste field
|
||||
TextField(
|
||||
controller: codeController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'sprout://...',
|
||||
prefixIcon: Icon(LucideIcons.link),
|
||||
isDense: true,
|
||||
),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enabled: !isConnecting,
|
||||
contextMenuBuilder: (context, editableTextState) {
|
||||
return AdaptiveTextSelectionToolbar.editableText(
|
||||
editableTextState: editableTextState,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.twelve),
|
||||
|
||||
// Connect button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: isConnecting
|
||||
? null
|
||||
: () {
|
||||
final code = codeController.text.trim();
|
||||
if (code.isNotEmpty) {
|
||||
ref.read(pairingProvider.notifier).pair(code);
|
||||
}
|
||||
},
|
||||
child: isConnecting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
),
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (pairingState.status == PairingStatus.error &&
|
||||
pairingState.errorMessage != null) ...[
|
||||
const SizedBox(height: Grid.twelve),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Grid.twelve),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.triangleAlert,
|
||||
size: 16,
|
||||
color: context.colors.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pairingState.errorMessage!,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(flex: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openScanner(BuildContext context, WidgetRef ref) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => _ScannerPage(
|
||||
onScanned: (code) {
|
||||
Navigator.of(context).pop();
|
||||
ref.read(pairingProvider.notifier).pair(code);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScannerPage extends StatefulWidget {
|
||||
final void Function(String code) onScanned;
|
||||
|
||||
const _ScannerPage({required this.onScanned});
|
||||
|
||||
@override
|
||||
State<_ScannerPage> createState() => _ScannerPageState();
|
||||
}
|
||||
|
||||
class _ScannerPageState extends State<_ScannerPage> {
|
||||
bool _handled = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Scan QR Code'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: MobileScanner(
|
||||
onDetect: (capture) {
|
||||
if (_handled) return;
|
||||
final barcodes = capture.barcodes;
|
||||
if (barcodes.isNotEmpty) {
|
||||
final value = barcodes.first.rawValue;
|
||||
if (value != null && value.isNotEmpty) {
|
||||
_handled = true;
|
||||
widget.onScanned(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
|
||||
/// HTTP client used by [PairingNotifier] for the validation request.
|
||||
/// Override in tests to inject a mock client.
|
||||
final pairingHttpClientProvider = Provider<http.Client>((ref) {
|
||||
final client = http.Client();
|
||||
ref.onDispose(client.close);
|
||||
return client;
|
||||
});
|
||||
|
||||
enum PairingStatus { idle, connecting, success, error }
|
||||
|
||||
class PairingState {
|
||||
final PairingStatus status;
|
||||
final String? errorMessage;
|
||||
|
||||
const PairingState({this.status = PairingStatus.idle, this.errorMessage});
|
||||
|
||||
PairingState copyWith({PairingStatus? status, String? errorMessage}) =>
|
||||
PairingState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
class PairingNotifier extends Notifier<PairingState> {
|
||||
@override
|
||||
PairingState build() => const PairingState();
|
||||
|
||||
Future<void> pair(String rawInput) async {
|
||||
if (state.status == PairingStatus.connecting) return;
|
||||
|
||||
state = const PairingState(status: PairingStatus.connecting);
|
||||
|
||||
try {
|
||||
final creds = _parseInput(rawInput);
|
||||
|
||||
// Test the connection before storing.
|
||||
final client = RelayClient(
|
||||
baseUrl: creds.relayUrl,
|
||||
apiToken: creds.token,
|
||||
httpClient: ref.read(pairingHttpClientProvider),
|
||||
);
|
||||
try {
|
||||
await client.get('/api/users/me/profile');
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
|
||||
await ref.read(authProvider.notifier).authenticate(creds);
|
||||
state = const PairingState(status: PairingStatus.success);
|
||||
} on FormatException catch (e) {
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Invalid pairing code: ${e.message}',
|
||||
);
|
||||
} on RelayException catch (e) {
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage:
|
||||
'Could not connect to relay (${e.statusCode}). '
|
||||
'Check that the pairing code is valid.',
|
||||
);
|
||||
} catch (e) {
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage:
|
||||
'Connection failed. Make sure your device can reach the '
|
||||
'relay server.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const PairingState();
|
||||
}
|
||||
|
||||
StoredCredentials _parseInput(String raw) {
|
||||
var payload = raw.trim();
|
||||
|
||||
if (payload.startsWith('sprout://')) {
|
||||
payload = payload.substring('sprout://'.length);
|
||||
}
|
||||
|
||||
// base64url decode.
|
||||
final normalized = base64Url.normalize(payload);
|
||||
final jsonStr = utf8.decode(base64Url.decode(normalized));
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('Pairing payload is not a JSON object');
|
||||
}
|
||||
|
||||
final relayUrl = decoded['relayUrl'] as String?;
|
||||
final token = decoded['token'] as String?;
|
||||
if (relayUrl == null || token == null) {
|
||||
throw const FormatException('Missing relayUrl or token');
|
||||
}
|
||||
|
||||
_validateRelayUrl(relayUrl);
|
||||
|
||||
return StoredCredentials(
|
||||
relayUrl: relayUrl,
|
||||
token: token,
|
||||
pubkey: decoded['pubkey'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Reject relay URLs that aren't HTTPS (unless debug mode) or target
|
||||
/// private/link-local addresses.
|
||||
void _validateRelayUrl(String url) {
|
||||
final uri = Uri.parse(url);
|
||||
|
||||
if (!kDebugMode && uri.scheme != 'https') {
|
||||
throw const FormatException('Relay URL must use HTTPS');
|
||||
}
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
throw FormatException('Invalid URL scheme: ${uri.scheme}');
|
||||
}
|
||||
|
||||
final host = uri.host.toLowerCase();
|
||||
if (host == 'localhost' || host == '127.0.0.1' || host == '::1') {
|
||||
if (!kDebugMode) {
|
||||
throw const FormatException('Relay URL cannot target localhost');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Block private and link-local IPs.
|
||||
final ip = Uri.tryParse('http://$host')?.host ?? host;
|
||||
if (_isPrivateHost(ip)) {
|
||||
throw const FormatException(
|
||||
'Relay URL cannot target private network addresses',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isPrivateHost(String host) {
|
||||
final parts = host.split('.');
|
||||
if (parts.length != 4) return false;
|
||||
final octets = parts.map(int.tryParse).toList();
|
||||
if (octets.any((o) => o == null)) return false;
|
||||
|
||||
final a = octets[0]!;
|
||||
final b = octets[1]!;
|
||||
|
||||
// 10.0.0.0/8
|
||||
if (a == 10) return true;
|
||||
// 172.16.0.0/12
|
||||
if (a == 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (a == 192 && b == 168) return true;
|
||||
// 169.254.0.0/16 (link-local / cloud metadata)
|
||||
if (a == 169 && b == 254) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final pairingProvider = NotifierProvider<PairingNotifier, PairingState>(
|
||||
PairingNotifier.new,
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
@@ -12,71 +12,42 @@ class SettingsPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final urlController = useTextEditingController(text: config.baseUrl);
|
||||
final tokenController = useTextEditingController(
|
||||
text: config.apiToken ?? '',
|
||||
);
|
||||
final pubkeyController = useTextEditingController(
|
||||
text: config.devPubkey ?? '',
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
children: [
|
||||
Text('Relay Connection', style: context.textTheme.titleMedium),
|
||||
// Connection info
|
||||
Text('Connection', style: context.textTheme.titleMedium),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
TextField(
|
||||
controller: urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Relay URL',
|
||||
hintText: 'http://localhost:3000',
|
||||
prefixIcon: Icon(LucideIcons.server),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.server),
|
||||
title: const Text('Connected to'),
|
||||
subtitle: Text(
|
||||
config.baseUrl,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: context.colors.outlineVariant),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Token (optional)',
|
||||
hintText: 'sprout_...',
|
||||
prefixIcon: Icon(LucideIcons.key),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _confirmSignOut(context, ref),
|
||||
icon: const Icon(LucideIcons.logOut),
|
||||
label: const Text('Sign Out'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: context.colors.error,
|
||||
),
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
TextField(
|
||||
controller: pubkeyController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Dev Pubkey (hex, for local relay)',
|
||||
hintText: '3bf0c63...',
|
||||
prefixIcon: const Icon(LucideIcons.userRound),
|
||||
),
|
||||
autocorrect: false,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final token = tokenController.text.trim();
|
||||
final pubkey = pubkeyController.text.trim();
|
||||
ref
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(
|
||||
baseUrl: urlController.text.trim(),
|
||||
apiToken: token.isEmpty ? null : token,
|
||||
devPubkey: pubkey.isEmpty ? null : pubkey,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Relay config updated')),
|
||||
);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
// Appearance
|
||||
Text('Appearance', style: context.textTheme.titleMedium),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
SegmentedButton<ThemeMode>(
|
||||
@@ -106,4 +77,33 @@ class SettingsPage extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmSignOut(BuildContext context, WidgetRef ref) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Sign Out'),
|
||||
content: const Text(
|
||||
'You will need to scan a new pairing code from your '
|
||||
'desktop app to reconnect.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
ref.read(authProvider.notifier).signOut();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.colors.error,
|
||||
),
|
||||
child: const Text('Sign Out'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'auth_provider.dart';
|
||||
export 'credential_storage.dart';
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../relay/relay.dart';
|
||||
import 'credential_storage.dart';
|
||||
|
||||
enum AuthStatus { unknown, unauthenticated, authenticated, offline }
|
||||
|
||||
class AuthState {
|
||||
final AuthStatus status;
|
||||
final StoredCredentials? credentials;
|
||||
|
||||
const AuthState({required this.status, this.credentials});
|
||||
}
|
||||
|
||||
class AuthNotifier extends AsyncNotifier<AuthState> {
|
||||
@override
|
||||
Future<AuthState> build() async {
|
||||
// If a token is provided via dart-define in debug mode, treat as
|
||||
// pre-authenticated.
|
||||
if (kDebugMode && Env.apiToken.isNotEmpty) {
|
||||
return const AuthState(status: AuthStatus.authenticated);
|
||||
}
|
||||
|
||||
final storage = CredentialStorage();
|
||||
final creds = await storage.load();
|
||||
if (creds == null) {
|
||||
return const AuthState(status: AuthStatus.unauthenticated);
|
||||
}
|
||||
|
||||
// Validate stored credentials against the relay.
|
||||
final client = RelayClient(baseUrl: creds.relayUrl, apiToken: creds.token);
|
||||
try {
|
||||
await client.get('/api/users/me/profile');
|
||||
// Credentials are valid — propagate to relay config.
|
||||
ref
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(
|
||||
baseUrl: creds.relayUrl,
|
||||
apiToken: creds.token,
|
||||
devPubkey: null,
|
||||
);
|
||||
return AuthState(status: AuthStatus.authenticated, credentials: creds);
|
||||
} on RelayException {
|
||||
// Token is invalid or expired — clear and require re-pairing.
|
||||
await storage.clear();
|
||||
return const AuthState(status: AuthStatus.unauthenticated);
|
||||
} catch (_) {
|
||||
// Network error — keep credentials and let the user retry.
|
||||
return AuthState(status: AuthStatus.offline, credentials: creds);
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry credential validation (e.g. after a network error).
|
||||
Future<void> retry() async {
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
|
||||
Future<void> authenticate(StoredCredentials creds) async {
|
||||
final storage = CredentialStorage();
|
||||
await storage.save(creds);
|
||||
|
||||
ref
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(
|
||||
baseUrl: creds.relayUrl,
|
||||
apiToken: creds.token,
|
||||
devPubkey: null,
|
||||
);
|
||||
|
||||
state = AsyncData(
|
||||
AuthState(status: AuthStatus.authenticated, credentials: creds),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
final storage = CredentialStorage();
|
||||
await storage.clear();
|
||||
state = const AsyncData(AuthState(status: AuthStatus.unauthenticated));
|
||||
}
|
||||
}
|
||||
|
||||
final authProvider = AsyncNotifierProvider<AuthNotifier, AuthState>(
|
||||
AuthNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class StoredCredentials {
|
||||
final String relayUrl;
|
||||
final String token;
|
||||
final String? pubkey;
|
||||
|
||||
const StoredCredentials({
|
||||
required this.relayUrl,
|
||||
required this.token,
|
||||
this.pubkey,
|
||||
});
|
||||
}
|
||||
|
||||
class CredentialStorage {
|
||||
static const _keyRelayUrl = 'sprout_relay_url';
|
||||
static const _keyToken = 'sprout_token';
|
||||
static const _keyPubkey = 'sprout_pubkey';
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
CredentialStorage([FlutterSecureStorage? storage])
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
Future<StoredCredentials?> load() async {
|
||||
final relayUrl = await _storage.read(key: _keyRelayUrl);
|
||||
final token = await _storage.read(key: _keyToken);
|
||||
if (relayUrl == null || token == null) return null;
|
||||
|
||||
final pubkey = await _storage.read(key: _keyPubkey);
|
||||
return StoredCredentials(relayUrl: relayUrl, token: token, pubkey: pubkey);
|
||||
}
|
||||
|
||||
Future<void> save(StoredCredentials credentials) async {
|
||||
await _storage.write(key: _keyRelayUrl, value: credentials.relayUrl);
|
||||
await _storage.write(key: _keyToken, value: credentials.token);
|
||||
if (credentials.pubkey != null) {
|
||||
await _storage.write(key: _keyPubkey, value: credentials.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _keyRelayUrl);
|
||||
await _storage.delete(key: _keyToken);
|
||||
await _storage.delete(key: _keyPubkey);
|
||||
}
|
||||
}
|
||||
@@ -73,5 +73,5 @@ class RelayException implements Exception {
|
||||
RelayException(this.statusCode, this.body);
|
||||
|
||||
@override
|
||||
String toString() => 'RelayException($statusCode): $body';
|
||||
String toString() => 'RelayException($statusCode)';
|
||||
}
|
||||
|
||||
+206
-1
@@ -121,6 +121,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -193,6 +201,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -238,11 +254,64 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -267,6 +336,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -307,6 +384,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -395,6 +496,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.11"
|
||||
mocktail:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -403,6 +512,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -411,6 +528,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -427,6 +552,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -688,6 +877,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -706,4 +911,4 @@ packages:
|
||||
version: "2.2.4"
|
||||
sdks:
|
||||
dart: ">=3.11.4 <4.0.0"
|
||||
flutter: ">=3.32.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
@@ -13,6 +13,8 @@ dependencies:
|
||||
flutter_hooks: ^0.21.3
|
||||
lucide_icons_flutter: ^3.1.0
|
||||
http: ^1.4.0
|
||||
flutter_secure_storage: ^9.2.4
|
||||
mobile_scanner: ^6.0.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sprout_mobile/features/pairing/pairing_page.dart';
|
||||
import 'package:sprout_mobile/features/pairing/pairing_provider.dart';
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('PairingPage', () {
|
||||
testWidgets('renders branding, scan button, divider, and text field', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(child: const PairingPage()),
|
||||
);
|
||||
|
||||
expect(find.text('Welcome to Sprout'), findsOneWidget);
|
||||
expect(find.text('Scan QR Code'), findsOneWidget);
|
||||
expect(find.text('or paste pairing code'), findsOneWidget);
|
||||
expect(find.text('Connect'), findsOneWidget);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('connect button is below text field, not beside it', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(child: const PairingPage()),
|
||||
);
|
||||
|
||||
final textField = tester.getBottomLeft(find.byType(TextField));
|
||||
final connectButton = tester.getTopLeft(
|
||||
find.widgetWithText(FilledButton, 'Connect'),
|
||||
);
|
||||
|
||||
// The connect button should be below the text field.
|
||||
expect(connectButton.dy, greaterThan(textField.dy));
|
||||
});
|
||||
|
||||
testWidgets('connect button is full width', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(child: const PairingPage()),
|
||||
);
|
||||
|
||||
final connectButton = tester.getSize(
|
||||
find.widgetWithText(FilledButton, 'Connect'),
|
||||
);
|
||||
final textField = tester.getSize(find.byType(TextField));
|
||||
|
||||
// Button width should be close to the text field width (both full-width).
|
||||
expect(connectButton.width, closeTo(textField.width, 2.0));
|
||||
});
|
||||
|
||||
testWidgets('shows error container when pairing fails', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
pairingProvider.overrideWith(
|
||||
() => _ErrorPairingNotifier('Invalid pairing code: bad input'),
|
||||
),
|
||||
],
|
||||
child: const PairingPage(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Invalid pairing code: bad input'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows spinner when connecting', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
pairingProvider.overrideWith(() => _ConnectingPairingNotifier()),
|
||||
],
|
||||
child: const PairingPage(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
// Connect text should be replaced by spinner.
|
||||
expect(find.text('Connect'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('text field and buttons disabled when connecting', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
pairingProvider.overrideWith(() => _ConnectingPairingNotifier()),
|
||||
],
|
||||
child: const PairingPage(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(textField.enabled, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _ErrorPairingNotifier extends Notifier<PairingState>
|
||||
implements PairingNotifier {
|
||||
final String error;
|
||||
_ErrorPairingNotifier(this.error);
|
||||
|
||||
@override
|
||||
PairingState build() =>
|
||||
PairingState(status: PairingStatus.error, errorMessage: error);
|
||||
|
||||
@override
|
||||
Future<void> pair(String rawInput) async {}
|
||||
|
||||
@override
|
||||
void reset() {}
|
||||
}
|
||||
|
||||
class _ConnectingPairingNotifier extends Notifier<PairingState>
|
||||
implements PairingNotifier {
|
||||
@override
|
||||
PairingState build() => const PairingState(status: PairingStatus.connecting);
|
||||
|
||||
@override
|
||||
Future<void> pair(String rawInput) async {}
|
||||
|
||||
@override
|
||||
void reset() {}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:sprout_mobile/features/pairing/pairing_provider.dart';
|
||||
import 'package:sprout_mobile/shared/auth/auth.dart';
|
||||
|
||||
/// Encode a credentials payload the same way the desktop app would.
|
||||
String _encodePairingCode({
|
||||
String relayUrl = 'http://test:3000',
|
||||
String token = 'sprout_test_token',
|
||||
String? pubkey,
|
||||
}) {
|
||||
final json = <String, dynamic>{
|
||||
'relayUrl': relayUrl,
|
||||
'token': token,
|
||||
// ignore: use_null_aware_elements
|
||||
if (pubkey != null) 'pubkey': pubkey,
|
||||
};
|
||||
return base64Url.encode(utf8.encode(jsonEncode(json)));
|
||||
}
|
||||
|
||||
/// A fake [AuthNotifier] that records calls instead of touching secure storage.
|
||||
class FakeAuthNotifier extends AsyncNotifier<AuthState>
|
||||
implements AuthNotifier {
|
||||
StoredCredentials? lastCredentials;
|
||||
bool signedOut = false;
|
||||
|
||||
@override
|
||||
Future<AuthState> build() async =>
|
||||
const AuthState(status: AuthStatus.unauthenticated);
|
||||
|
||||
@override
|
||||
Future<void> authenticate(StoredCredentials creds) async {
|
||||
lastCredentials = creds;
|
||||
state = AsyncData(
|
||||
AuthState(status: AuthStatus.authenticated, credentials: creds),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
signedOut = true;
|
||||
state = const AsyncData(AuthState(status: AuthStatus.unauthenticated));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> retry() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('PairingNotifier', () {
|
||||
late ProviderContainer container;
|
||||
late FakeAuthNotifier fakeAuth;
|
||||
|
||||
/// Creates a container with the HTTP client wired to [mockClient].
|
||||
ProviderContainer createContainer(http_testing.MockClient mockClient) {
|
||||
fakeAuth = FakeAuthNotifier();
|
||||
return ProviderContainer(
|
||||
overrides: [
|
||||
authProvider.overrideWith(() => fakeAuth),
|
||||
pairingHttpClientProvider.overrideWithValue(mockClient),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
tearDown(() => container.dispose());
|
||||
|
||||
test('starts in idle state', () {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response('{}', 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.idle);
|
||||
expect(state.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('successful pairing with raw base64 code', () async {
|
||||
final mock = http_testing.MockClient((request) async {
|
||||
expect(request.url.path, '/api/users/me/profile');
|
||||
return http.Response(jsonEncode({'id': '1', 'name': 'Wes'}), 200);
|
||||
});
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = _encodePairingCode();
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.success);
|
||||
expect(fakeAuth.lastCredentials?.relayUrl, 'http://test:3000');
|
||||
expect(fakeAuth.lastCredentials?.token, 'sprout_test_token');
|
||||
});
|
||||
|
||||
test('successful pairing with sprout:// prefix', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response(jsonEncode({'id': '1'}), 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = 'sprout://${_encodePairingCode()}';
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
expect(container.read(pairingProvider).status, PairingStatus.success);
|
||||
});
|
||||
|
||||
test('successful pairing with pubkey', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response(jsonEncode({'id': '1'}), 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = _encodePairingCode(pubkey: 'abc123');
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
expect(container.read(pairingProvider).status, PairingStatus.success);
|
||||
expect(fakeAuth.lastCredentials?.pubkey, 'abc123');
|
||||
});
|
||||
|
||||
test('relay 401 sets error state', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response('{"error": "unauthorized"}', 401),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = _encodePairingCode();
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.error);
|
||||
expect(state.errorMessage, contains('Could not connect to relay'));
|
||||
expect(state.errorMessage, contains('401'));
|
||||
});
|
||||
|
||||
test('relay 500 sets error state', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response('internal error', 500),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = _encodePairingCode();
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.error);
|
||||
expect(state.errorMessage, contains('500'));
|
||||
});
|
||||
|
||||
test('network error sets generic error', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) => throw Exception('no internet'),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = _encodePairingCode();
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.error);
|
||||
expect(state.errorMessage, contains('Connection failed'));
|
||||
});
|
||||
|
||||
test('invalid base64 sets format error', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response('{}', 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
await container.read(pairingProvider.notifier).pair('not-valid!!!');
|
||||
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.error);
|
||||
expect(state.errorMessage, contains('Invalid pairing code'));
|
||||
});
|
||||
|
||||
test('base64 with valid JSON but missing fields errors', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response('{}', 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
// Valid base64 JSON, but no relayUrl/token keys.
|
||||
final code = base64Url.encode(utf8.encode(jsonEncode({'foo': 'bar'})));
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.error);
|
||||
expect(state.errorMessage, contains('Missing relayUrl or token'));
|
||||
});
|
||||
|
||||
test('empty input errors', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response('{}', 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
await container.read(pairingProvider.notifier).pair('');
|
||||
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.error);
|
||||
});
|
||||
|
||||
test('whitespace-padded input is trimmed', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response(jsonEncode({'id': '1'}), 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = ' ${_encodePairingCode()} \n';
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
expect(container.read(pairingProvider).status, PairingStatus.success);
|
||||
});
|
||||
|
||||
test('rejects private IP relay URLs (SSRF)', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response('{}', 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
for (final ip in [
|
||||
'10.0.0.1',
|
||||
'172.16.0.1',
|
||||
'192.168.1.1',
|
||||
'169.254.169.254',
|
||||
]) {
|
||||
final code = _encodePairingCode(relayUrl: 'http://$ip:3000');
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.error, reason: 'should reject $ip');
|
||||
expect(state.errorMessage, contains('private network'));
|
||||
container.read(pairingProvider.notifier).reset();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-http/https schemes', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response('{}', 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = _encodePairingCode(relayUrl: 'file:///etc/passwd');
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.error);
|
||||
expect(state.errorMessage, contains('Invalid pairing code'));
|
||||
});
|
||||
|
||||
test('rejects JSON array payload', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response('{}', 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = base64Url.encode(utf8.encode(jsonEncode([1, 2, 3])));
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
final state = container.read(pairingProvider);
|
||||
expect(state.status, PairingStatus.error);
|
||||
expect(state.errorMessage, contains('not a JSON object'));
|
||||
});
|
||||
|
||||
test('ignores duplicate pair() calls while connecting', () async {
|
||||
var requestCount = 0;
|
||||
final mock = http_testing.MockClient((_) async {
|
||||
requestCount++;
|
||||
// Simulate slow network.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return http.Response(jsonEncode({'id': '1'}), 200);
|
||||
});
|
||||
container = createContainer(mock);
|
||||
|
||||
final code = _encodePairingCode();
|
||||
// Fire two calls without awaiting the first.
|
||||
final f1 = container.read(pairingProvider.notifier).pair(code);
|
||||
final f2 = container.read(pairingProvider.notifier).pair(code);
|
||||
await Future.wait([f1, f2]);
|
||||
|
||||
// Only one network request should have been made.
|
||||
expect(requestCount, 1);
|
||||
});
|
||||
|
||||
test('reset returns to idle', () async {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response(jsonEncode({'id': '1'}), 200),
|
||||
);
|
||||
container = createContainer(mock);
|
||||
|
||||
await container.read(pairingProvider.notifier).pair(_encodePairingCode());
|
||||
expect(container.read(pairingProvider).status, PairingStatus.success);
|
||||
|
||||
container.read(pairingProvider.notifier).reset();
|
||||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,26 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sprout_mobile/app.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sprout_mobile/app.dart';
|
||||
import 'package:sprout_mobile/shared/auth/auth.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App renders without crashing', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: App()));
|
||||
expect(find.text('Channels'), findsWidgets);
|
||||
testWidgets('App renders pairing page when unauthenticated', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [authProvider.overrideWith(() => _FakeAuthNotifier())],
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('Welcome to Sprout'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeAuthNotifier extends AuthNotifier {
|
||||
@override
|
||||
Future<AuthState> build() async {
|
||||
return const AuthState(status: AuthStatus.unauthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user