This commit is contained in:
parent
4a97801080
commit
d5fc89b97a
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -66,6 +66,8 @@ desktop.ini
|
|||
*.tar.gz
|
||||
*.tar
|
||||
*.gz
|
||||
*.backup
|
||||
*.orig
|
||||
*.exe
|
||||
*.bin
|
||||
*.db
|
||||
|
|
|
|||
39
beta.sojorn.net.nginx
Normal file
39
beta.sojorn.net.nginx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name beta.sojorn.net;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name beta.sojorn.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/beta.sojorn.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/beta.sojorn.net/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
root /var/www/beta.sojorn.net;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
}
|
||||
62
deploy_all_functions.ps1
Normal file
62
deploy_all_functions.ps1
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Deploy all Edge Functions to Supabase
|
||||
# Run this after updating supabase-js version
|
||||
|
||||
Write-Host "=== Deploying All Edge Functions ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "This will deploy all functions with --no-verify-jwt (default for this script)" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
$functions = @(
|
||||
"appreciate",
|
||||
"block",
|
||||
"deactivate-account",
|
||||
"delete-account",
|
||||
"feed-personal",
|
||||
"feed-sojorn",
|
||||
"follow",
|
||||
"manage-post",
|
||||
"notifications",
|
||||
"profile",
|
||||
"profile-posts",
|
||||
"publish-comment",
|
||||
"publish-post",
|
||||
"push-notification",
|
||||
"report",
|
||||
"save",
|
||||
"search",
|
||||
"sign-media",
|
||||
"signup",
|
||||
"tone-check",
|
||||
"trending",
|
||||
"upload-image"
|
||||
)
|
||||
|
||||
$totalFunctions = $functions.Count
|
||||
$currentFunction = 0
|
||||
$noVerifyJwt = "--no-verify-jwt"
|
||||
|
||||
foreach ($func in $functions) {
|
||||
$currentFunction++
|
||||
Write-Host "[$currentFunction/$totalFunctions] Deploying $func..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
supabase functions deploy $func $noVerifyJwt 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host " OK $func deployed successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " FAILED to deploy $func" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " ERROR deploying $func : $_" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Deployment Complete ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:" -ForegroundColor Yellow
|
||||
Write-Host "1. Restart your Flutter app" -ForegroundColor Yellow
|
||||
Write-Host "2. Sign in again" -ForegroundColor Yellow
|
||||
Write-Host "3. The JWT 401 errors should be gone!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
BIN
indiegogo-banner-big.png
Normal file
BIN
indiegogo-banner-big.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 389 KiB |
BIN
indiegogo-banner.png
Normal file
BIN
indiegogo-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
BIN
indiegogo-launch.png
Normal file
BIN
indiegogo-launch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
BIN
indiegogo.png
Normal file
BIN
indiegogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
4306
logo [Recovered].ai
4306
logo [Recovered].ai
File diff suppressed because one or more lines are too long
79
run_dev.ps1
Normal file
79
run_dev.ps1
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# run_dev.ps1 — Run Sojorn on the default connected device (mobile/emulator)
|
||||
# Usage: .\run_dev.ps1 [-EnvPath .env]
|
||||
param(
|
||||
[string]$EnvPath = ""
|
||||
)
|
||||
|
||||
$RepoRoot = $PSScriptRoot
|
||||
$AppPath = Join-Path $RepoRoot "sojorn_app"
|
||||
if ([string]::IsNullOrWhiteSpace($EnvPath)) {
|
||||
$EnvPath = Join-Path $RepoRoot ".env"
|
||||
}
|
||||
if (-not (Test-Path $AppPath)) {
|
||||
Write-Error "sojorn_app not found at ${AppPath}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-EnvValues($path) {
|
||||
$vals = @{}
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||
return $vals
|
||||
}
|
||||
Get-Content $path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { return }
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -lt 2) { return }
|
||||
$key = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$vals[$key] = $value
|
||||
}
|
||||
return $vals
|
||||
}
|
||||
|
||||
$values = Get-EnvValues $EnvPath
|
||||
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
} else {
|
||||
$currentApi = $values['API_BASE_URL']
|
||||
}
|
||||
|
||||
Write-Host "Launching Sojorn (device)..." -ForegroundColor Cyan
|
||||
Write-Host "API: $currentApi"
|
||||
|
||||
$flutterCmd = Get-Command flutter -ErrorAction SilentlyContinue
|
||||
if ($flutterCmd) {
|
||||
$FlutterExe = $flutterCmd.Source
|
||||
} else {
|
||||
$fallbackFlutter = "C:\Users\Patrick\develop\flutter\flutter\bin\flutter.bat"
|
||||
if (Test-Path $fallbackFlutter) {
|
||||
$FlutterExe = $fallbackFlutter
|
||||
} else {
|
||||
Write-Error "flutter command not found in PATH and fallback not found at $fallbackFlutter"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $AppPath
|
||||
try {
|
||||
Write-Host "Running: flutter run $($defineArgs -join ' ')" -ForegroundColor DarkGray
|
||||
& $FlutterExe run @defineArgs @Args
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
87
run_web.ps1
Normal file
87
run_web.ps1
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# run_web.ps1 — Run Sojorn in Edge browser
|
||||
# Usage: .\run_web.ps1 [-Port 8001] [-EnvPath .env]
|
||||
param(
|
||||
[string]$EnvPath = "",
|
||||
[int]$Port = 8001,
|
||||
[switch]$NoWasmDryRun
|
||||
)
|
||||
|
||||
$RepoRoot = $PSScriptRoot
|
||||
$AppPath = Join-Path $RepoRoot "sojorn_app"
|
||||
if ([string]::IsNullOrWhiteSpace($EnvPath)) {
|
||||
$EnvPath = Join-Path $RepoRoot ".env"
|
||||
}
|
||||
if (-not (Test-Path $AppPath)) {
|
||||
Write-Error "sojorn_app not found at ${AppPath}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-EnvValues($path) {
|
||||
$vals = @{}
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||
return $vals
|
||||
}
|
||||
Get-Content $path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { return }
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -lt 2) { return }
|
||||
$key = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$vals[$key] = $value
|
||||
}
|
||||
return $vals
|
||||
}
|
||||
|
||||
$values = Get-EnvValues $EnvPath
|
||||
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
} else {
|
||||
$currentApi = $values['API_BASE_URL']
|
||||
}
|
||||
|
||||
Write-Host "Launching Sojorn Web (Edge)..." -ForegroundColor Cyan
|
||||
Write-Host "Port: $Port | API: $currentApi"
|
||||
|
||||
$flutterCmd = Get-Command flutter -ErrorAction SilentlyContinue
|
||||
if ($flutterCmd) {
|
||||
$FlutterExe = $flutterCmd.Source
|
||||
} else {
|
||||
$fallbackFlutter = "C:\Users\Patrick\develop\flutter\flutter\bin\flutter.bat"
|
||||
if (Test-Path $fallbackFlutter) {
|
||||
$FlutterExe = $fallbackFlutter
|
||||
} else {
|
||||
Write-Error "flutter command not found in PATH and fallback not found at $fallbackFlutter"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $AppPath
|
||||
try {
|
||||
$cmdArgs = @('run', '-d', 'edge', '--web-hostname', 'localhost', '--web-port', "$Port")
|
||||
if ($NoWasmDryRun) {
|
||||
$cmdArgs += '--no-wasm-dry-run'
|
||||
}
|
||||
$cmdArgs += $defineArgs
|
||||
|
||||
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||
& $FlutterExe $cmdArgs
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
83
run_web_chrome.ps1
Normal file
83
run_web_chrome.ps1
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# run_web_chrome.ps1 — Run Sojorn in Chrome browser
|
||||
# Usage: .\run_web_chrome.ps1 [-Port 8002] [-EnvPath .env]
|
||||
param(
|
||||
[string]$EnvPath = "",
|
||||
[int]$Port = 8002
|
||||
)
|
||||
|
||||
$RepoRoot = $PSScriptRoot
|
||||
$AppPath = Join-Path $RepoRoot "sojorn_app"
|
||||
if ([string]::IsNullOrWhiteSpace($EnvPath)) {
|
||||
$EnvPath = Join-Path $RepoRoot ".env"
|
||||
}
|
||||
if (-not (Test-Path $AppPath)) {
|
||||
Write-Error "sojorn_app not found at ${AppPath}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-EnvValues($path) {
|
||||
$vals = @{}
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||
return $vals
|
||||
}
|
||||
Get-Content $path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { return }
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -lt 2) { return }
|
||||
$key = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$vals[$key] = $value
|
||||
}
|
||||
return $vals
|
||||
}
|
||||
|
||||
$values = Get-EnvValues $EnvPath
|
||||
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
} else {
|
||||
$currentApi = $values['API_BASE_URL']
|
||||
}
|
||||
|
||||
Write-Host "Launching Sojorn Web (Chrome)..." -ForegroundColor Cyan
|
||||
Write-Host "Port: $Port | API: $currentApi"
|
||||
|
||||
$flutterCmd = Get-Command flutter -ErrorAction SilentlyContinue
|
||||
if ($flutterCmd) {
|
||||
$FlutterExe = $flutterCmd.Source
|
||||
} else {
|
||||
$fallbackFlutter = "C:\Users\Patrick\develop\flutter\flutter\bin\flutter.bat"
|
||||
if (Test-Path $fallbackFlutter) {
|
||||
$FlutterExe = $fallbackFlutter
|
||||
} else {
|
||||
Write-Error "flutter command not found in PATH and fallback not found at $fallbackFlutter"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $AppPath
|
||||
try {
|
||||
$cmdArgs = @('run', '-d', 'chrome', '--web-hostname', 'localhost', '--web-port', "$Port")
|
||||
$cmdArgs += $defineArgs
|
||||
|
||||
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||
& $FlutterExe $cmdArgs
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
137
run_windows.ps1
Normal file
137
run_windows.ps1
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
param(
|
||||
[string]$EnvPath = "",
|
||||
[switch]$Release,
|
||||
[switch]$Clean
|
||||
)
|
||||
|
||||
$RepoRoot = $PSScriptRoot
|
||||
$AppPath = Join-Path $RepoRoot "sojorn_app"
|
||||
if ([string]::IsNullOrWhiteSpace($EnvPath)) {
|
||||
$EnvPath = Join-Path $RepoRoot ".env"
|
||||
}
|
||||
if (-not (Test-Path $AppPath)) {
|
||||
Write-Error "sojorn_app not found at ${AppPath}"
|
||||
exit 1
|
||||
}
|
||||
$flutterCmd = Get-Command flutter -ErrorAction SilentlyContinue
|
||||
if ($flutterCmd) {
|
||||
$FlutterExe = $flutterCmd.Source
|
||||
} else {
|
||||
$fallbackFlutter = "C:\Users\Patrick\develop\flutter\flutter\bin\flutter.bat"
|
||||
if (Test-Path $fallbackFlutter) {
|
||||
$FlutterExe = $fallbackFlutter
|
||||
} else {
|
||||
Write-Error "flutter command not found in PATH and fallback not found at $fallbackFlutter"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
function Get-EnvValues($path) {
|
||||
$vals = @{}
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||
# Set default API_BASE_URL since no .env exists
|
||||
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||
return $vals
|
||||
}
|
||||
Get-Content $path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { return }
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -lt 2) { return }
|
||||
$key = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$vals[$key] = $value
|
||||
}
|
||||
return $vals
|
||||
}
|
||||
|
||||
$values = Get-EnvValues $EnvPath
|
||||
|
||||
# Collect dart-defines we actually use on Windows.
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure API_BASE_URL is set
|
||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
}
|
||||
else {
|
||||
$currentApi = $values['API_BASE_URL']
|
||||
if ($currentApi.StartsWith('http://localhost:')) {
|
||||
# For local development, keep localhost but warn
|
||||
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Building Sojorn for Windows..." -ForegroundColor Cyan
|
||||
Write-Host "API: $currentApi"
|
||||
Write-Host "Mode: $(if ($Release) { 'Release' } else { 'Debug' })"
|
||||
|
||||
Push-Location $AppPath
|
||||
try {
|
||||
# Clean build if requested
|
||||
if ($Clean) {
|
||||
Write-Host "Cleaning build cache..." -ForegroundColor Yellow
|
||||
& $FlutterExe clean
|
||||
& $FlutterExe pub get
|
||||
}
|
||||
|
||||
# Build for Windows
|
||||
$cmdArgs = @(
|
||||
'build',
|
||||
'windows'
|
||||
)
|
||||
|
||||
if ($Release) {
|
||||
$cmdArgs += '--release'
|
||||
}
|
||||
else {
|
||||
$cmdArgs += '--debug'
|
||||
}
|
||||
|
||||
$cmdArgs += $defineArgs
|
||||
|
||||
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||
& $FlutterExe $cmdArgs
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$buildPath = Join-Path (Get-Location) "build\windows\x64\runner\Release"
|
||||
if (-not $Release) {
|
||||
$buildPath = Join-Path (Get-Location) "build\windows\x64\runner\Debug"
|
||||
}
|
||||
|
||||
$exePath = Join-Path $buildPath "sojorn_app.exe"
|
||||
|
||||
if (Test-Path $exePath) {
|
||||
Write-Host "Build successful!" -ForegroundColor Green
|
||||
Write-Host "Executable: $exePath" -ForegroundColor Cyan
|
||||
Write-Host "Build folder: $buildPath" -ForegroundColor Cyan
|
||||
|
||||
# Ask if user wants to run the app
|
||||
$response = Read-Host "Do you want to run the app now? (y/n)"
|
||||
if ($response -eq 'y' -or $response -eq 'Y') {
|
||||
Write-Host "Starting Sojorn Windows app..." -ForegroundColor Yellow
|
||||
Start-Process -FilePath $exePath
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "Build completed but executable not found at: $exePath" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "Build failed!" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
35
run_windows_app.ps1
Normal file
35
run_windows_app.ps1
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
param(
|
||||
[string]$BuildType = "Release", # Release or Debug
|
||||
[switch]$BuildFirst
|
||||
)
|
||||
|
||||
$RepoRoot = $PSScriptRoot
|
||||
$buildPath = Join-Path $RepoRoot "sojorn_app\build\windows\x64\runner\$BuildType"
|
||||
$exePath = Join-Path $buildPath "sojorn_app.exe"
|
||||
|
||||
Write-Host "Sojorn Windows App Runner" -ForegroundColor Cyan
|
||||
Write-Host "Build Type: $BuildType" -ForegroundColor White
|
||||
Write-Host "Executable: $exePath" -ForegroundColor White
|
||||
|
||||
# Build first if requested
|
||||
if ($BuildFirst) {
|
||||
Write-Host "Building first..." -ForegroundColor Yellow
|
||||
& "$PSScriptRoot\run_windows.ps1" -Release:($BuildType -eq "Release")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Build failed, cannot run app." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Check if executable exists
|
||||
if (-not (Test-Path $exePath)) {
|
||||
Write-Host "Executable not found: $exePath" -ForegroundColor Red
|
||||
Write-Host "Try building first with: .\run_windows.ps1 -Release" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run the app
|
||||
Write-Host "Starting Sojorn Windows app..." -ForegroundColor Green
|
||||
Start-Process -FilePath $exePath -WorkingDirectory $buildPath
|
||||
|
||||
Write-Host "App launched! Check your taskbar or desktop for the Sojorn window." -ForegroundColor Cyan
|
||||
|
|
@ -133,14 +133,14 @@ class _sojornAppState extends ConsumerState<sojornApp> with WidgetsBindingObserv
|
|||
// Defer heavy work with real delays to avoid jank on first paint
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (kDebugMode) debugPrint('[APP] Post-frame: starting deferred init');
|
||||
// Stagger init with real delays so the UI can paint between tasks
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
// Stagger init with longer delays to reduce jank from heavy synchronous work
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
_initE2ee();
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
_initNotifications();
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
Future.delayed(const Duration(milliseconds: 1200), () {
|
||||
_initSyncManagerIfAuthenticated();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -534,25 +534,27 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
),
|
||||
],
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'New to Sojorn? ',
|
||||
style: AppTheme.textTheme.bodyMedium,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SignUpScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Create an account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!kIsWeb) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'New to Sojorn? ',
|
||||
style: AppTheme.textTheme.bodyMedium,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SignUpScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Create an account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -25,9 +25,18 @@ class GroupForumTab extends StatefulWidget {
|
|||
class _GroupForumTabState extends State<GroupForumTab> {
|
||||
List<Map<String, dynamic>> _threads = [];
|
||||
bool _loading = true;
|
||||
String? _selectedCategory;
|
||||
String? _activeSubforum;
|
||||
|
||||
static const _categories = ['General', 'Events', 'Information', 'Safety', 'Recommendations', 'Marketplace'];
|
||||
static const _subforums = ['General', 'Events', 'Information', 'Safety', 'Recommendations', 'Marketplace'];
|
||||
|
||||
static const _subforumDescriptions = {
|
||||
'General': 'Open neighborhood discussion',
|
||||
'Events': 'Plans, meetups, and happenings',
|
||||
'Information': 'Updates, notices, and resources',
|
||||
'Safety': 'Alerts and local safety conversations',
|
||||
'Recommendations': 'Trusted local picks and referrals',
|
||||
'Marketplace': 'Buy, sell, and trade nearby',
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -41,10 +50,12 @@ class _GroupForumTabState extends State<GroupForumTab> {
|
|||
if (widget.isEncrypted) {
|
||||
await _loadEncryptedThreads();
|
||||
} else {
|
||||
// Direct call to support category filtering
|
||||
final queryParams = <String, String>{'limit': '30'};
|
||||
if (_selectedCategory != null) {
|
||||
queryParams['category'] = _selectedCategory!;
|
||||
// Non-encrypted neighborhood forums support sub-forums via category.
|
||||
final queryParams = <String, String>{
|
||||
'limit': _activeSubforum == null ? '120' : '30',
|
||||
};
|
||||
if (_activeSubforum != null) {
|
||||
queryParams['category'] = _activeSubforum!;
|
||||
}
|
||||
final data = await ApiService.instance.callGoApi(
|
||||
'/capsules/${widget.groupId}/threads',
|
||||
|
|
@ -107,7 +118,8 @@ class _GroupForumTabState extends State<GroupForumTab> {
|
|||
isScrollControlled: true,
|
||||
builder: (ctx) => _NewThreadSheet(
|
||||
isEncrypted: widget.isEncrypted,
|
||||
initialCategory: _selectedCategory,
|
||||
initialCategory: widget.isEncrypted ? null : (_activeSubforum ?? 'General'),
|
||||
lockCategory: !widget.isEncrypted && _activeSubforum != null,
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
|
@ -177,145 +189,239 @@ class _GroupForumTabState extends State<GroupForumTab> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showSubforumDirectory = !widget.isEncrypted && _activeSubforum == null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Category Filters
|
||||
if (!widget.isEncrypted)
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
color: AppTheme.cardSurface,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
if (showSubforumDirectory)
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildSubforumDirectory(),
|
||||
)
|
||||
else ...[
|
||||
if (!widget.isEncrypted)
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
color: AppTheme.cardSurface,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildCategoryChip(null, 'All'),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () {
|
||||
setState(() => _activeSubforum = null);
|
||||
_loadThreads();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.arrow_back, size: 15, color: SojornColors.textDisabled),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Subforums',
|
||||
style: TextStyle(
|
||||
color: SojornColors.textDisabled,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
..._categories.map((c) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildCategoryChip(c, c),
|
||||
)),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
_activeSubforum ?? 'General',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.brightNavy,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
_loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _threads.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.forum, size: 48, color: AppTheme.navyBlue.withValues(alpha: 0.15)),
|
||||
const SizedBox(height: 12),
|
||||
Text('No discussions yet', style: TextStyle(color: SojornColors.postContentLight, fontSize: 14)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Start a thread to get the conversation going',
|
||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadThreads,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 80),
|
||||
itemCount: _threads.length,
|
||||
separatorBuilder: (_, __) => Divider(color: AppTheme.navyBlue.withValues(alpha: 0.06), height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
final thread = _threads[i];
|
||||
final title = thread['title'] as String? ?? 'Untitled';
|
||||
final body = thread['body'] as String? ?? '';
|
||||
final category = thread['category'] as String? ?? '';
|
||||
final handle = thread['author_handle'] as String? ?? '';
|
||||
final displayName = thread['author_display_name'] as String? ?? handle;
|
||||
final replyCount = thread['reply_count'] as int? ?? 0;
|
||||
final createdAt = thread['created_at']?.toString() ?? thread['last_activity_at']?.toString();
|
||||
|
||||
return ListTile(
|
||||
onTap: () => _openThread(thread),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
title: Row(
|
||||
children: [
|
||||
if (category.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(category, style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppTheme.brightNavy)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
_loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _threads.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.forum, size: 48, color: AppTheme.navyBlue.withValues(alpha: 0.15)),
|
||||
const SizedBox(height: 12),
|
||||
Text('No discussions yet', style: TextStyle(color: SojornColors.postContentLight, fontSize: 14)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Start a thread to get the conversation going',
|
||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 12)),
|
||||
],
|
||||
Expanded(child: Text(title, style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 14))),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadThreads,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 80),
|
||||
itemCount: _threads.length,
|
||||
separatorBuilder: (_, __) => Divider(color: AppTheme.navyBlue.withValues(alpha: 0.06), height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
final thread = _threads[i];
|
||||
final title = thread['title'] as String? ?? 'Untitled';
|
||||
final body = thread['body'] as String? ?? '';
|
||||
final category = thread['category'] as String? ?? '';
|
||||
final handle = thread['author_handle'] as String? ?? '';
|
||||
final displayName = thread['author_display_name'] as String? ?? handle;
|
||||
final replyCount = thread['reply_count'] as int? ?? 0;
|
||||
final createdAt = thread['created_at']?.toString() ?? thread['last_activity_at']?.toString();
|
||||
|
||||
return ListTile(
|
||||
onTap: () => _openThread(thread),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
title: Row(
|
||||
children: [
|
||||
if (category.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(category, style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppTheme.brightNavy)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(child: Text(title, style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 14))),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (body.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(body, maxLines: 2, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: SojornColors.postContentLight, fontSize: 12)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Text(displayName.isNotEmpty ? displayName : handle,
|
||||
style: TextStyle(color: AppTheme.brightNavy, fontSize: 11, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chat_bubble_outline, size: 12, color: SojornColors.textDisabled),
|
||||
const SizedBox(width: 3),
|
||||
Text('$replyCount', style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)),
|
||||
const SizedBox(width: 8),
|
||||
Text(_timeAgo(createdAt), style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, size: 18, color: SojornColors.textDisabled),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (body.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(body, maxLines: 2, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: SojornColors.postContentLight, fontSize: 12)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Text(displayName.isNotEmpty ? displayName : handle,
|
||||
style: TextStyle(color: AppTheme.brightNavy, fontSize: 11, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chat_bubble_outline, size: 12, color: SojornColors.textDisabled),
|
||||
const SizedBox(width: 3),
|
||||
Text('$replyCount', style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)),
|
||||
const SizedBox(width: 8),
|
||||
Text(_timeAgo(createdAt), style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, size: 18, color: SojornColors.textDisabled),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16, right: 16,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'new_thread',
|
||||
onPressed: _showCreateThread,
|
||||
backgroundColor: widget.isEncrypted ? const Color(0xFF4CAF50) : AppTheme.brightNavy,
|
||||
child: const Icon(Icons.add, color: SojornColors.basicWhite),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16, right: 16,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'new_thread',
|
||||
onPressed: _showCreateThread,
|
||||
backgroundColor: widget.isEncrypted ? const Color(0xFF4CAF50) : AppTheme.brightNavy,
|
||||
child: const Icon(Icons.add, color: SojornColors.basicWhite),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryChip(String? category, String label) {
|
||||
final isSelected = _selectedCategory == category;
|
||||
return FilterChip(
|
||||
selected: isSelected,
|
||||
label: Text(label),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w600,
|
||||
color: isSelected ? SojornColors.basicWhite : AppTheme.navyBlue,
|
||||
),
|
||||
backgroundColor: AppTheme.scaffoldBg,
|
||||
selectedColor: AppTheme.brightNavy,
|
||||
checkmarkColor: SojornColors.basicWhite,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: isSelected ? Colors.transparent : AppTheme.navyBlue.withValues(alpha: 0.1)),
|
||||
),
|
||||
onSelected: (_) {
|
||||
setState(() => _selectedCategory = isSelected ? null : category);
|
||||
_loadThreads();
|
||||
Widget _buildSubforumDirectory() {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 20),
|
||||
itemCount: _subforums.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final subforum = _subforums[i];
|
||||
final count = _threads.where((t) => (t['category'] as String? ?? 'General') == subforum).length;
|
||||
final description = _subforumDescriptions[subforum] ?? '';
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
setState(() => _activeSubforum = subforum);
|
||||
_loadThreads();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Icon(Icons.forum, size: 18, color: AppTheme.brightNavy),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
subforum,
|
||||
style: TextStyle(
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
color: SojornColors.textDisabled,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
color: AppTheme.brightNavy,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.chevron_right, size: 18, color: SojornColors.textDisabled),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -325,7 +431,12 @@ class _GroupForumTabState extends State<GroupForumTab> {
|
|||
class _NewThreadSheet extends StatefulWidget {
|
||||
final bool isEncrypted;
|
||||
final String? initialCategory;
|
||||
const _NewThreadSheet({this.isEncrypted = false, this.initialCategory});
|
||||
final bool lockCategory;
|
||||
const _NewThreadSheet({
|
||||
this.isEncrypted = false,
|
||||
this.initialCategory,
|
||||
this.lockCategory = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_NewThreadSheet> createState() => _NewThreadSheetState();
|
||||
|
|
@ -372,7 +483,7 @@ class _NewThreadSheetState extends State<_NewThreadSheet> {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
// Category Selector
|
||||
if (!widget.isEncrypted) ...[
|
||||
if (!widget.isEncrypted && !widget.lockCategory) ...[
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
|
|
@ -398,6 +509,23 @@ class _NewThreadSheetState extends State<_NewThreadSheet> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
] else if (!widget.isEncrypted && widget.lockCategory) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Posting in $_selectedCategory',
|
||||
style: TextStyle(
|
||||
color: AppTheme.brightNavy,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
TextField(
|
||||
controller: _bodyCtrl,
|
||||
|
|
|
|||
|
|
@ -261,6 +261,8 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
|||
_buildBeaconCreateButton()
|
||||
else
|
||||
IconButton(
|
||||
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
|
||||
padding: const EdgeInsets.all(12),
|
||||
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
||||
tooltip: 'Discover',
|
||||
onPressed: () {
|
||||
|
|
@ -271,7 +273,10 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
|||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
|
||||
padding: const EdgeInsets.all(12),
|
||||
icon: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final badge = ref.watch(currentBadgeProvider);
|
||||
|
|
@ -293,7 +298,10 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
|||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
IconButton(
|
||||
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
|
||||
padding: const EdgeInsets.all(12),
|
||||
icon: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final badge = ref.watch(currentBadgeProvider);
|
||||
|
|
@ -314,7 +322,7 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
|||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -323,7 +331,7 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
|||
final beaconState = BeaconScreen.globalKey.currentState;
|
||||
final label = beaconState?.createLabel ?? 'Create';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2),
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {
|
||||
final state = BeaconScreen.globalKey.currentState;
|
||||
|
|
@ -340,9 +348,10 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
|||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue,
|
||||
foregroundColor: SojornColors.basicWhite,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
minimumSize: const Size(0, 34),
|
||||
minimumSize: const Size(0, 38),
|
||||
elevation: 1.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -204,6 +204,10 @@ class AuthService {
|
|||
}) async {
|
||||
try {
|
||||
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
|
||||
// DEBUG: Log the API URL being used
|
||||
print('[AUTH] Login URL: $uri');
|
||||
print('[AUTH] API_BASE_URL from env: ${ApiConfig.baseUrl}');
|
||||
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'package:cloudflare_turnstile/cloudflare_turnstile.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../config/api_config.dart';
|
||||
|
||||
class TurnstileWidget extends StatelessWidget {
|
||||
class TurnstileWidget extends StatefulWidget {
|
||||
final String siteKey;
|
||||
final ValueChanged<String> onToken;
|
||||
final String? baseUrl;
|
||||
|
|
@ -15,20 +15,50 @@ class TurnstileWidget extends StatelessWidget {
|
|||
this.baseUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TurnstileWidget> createState() => _TurnstileWidgetState();
|
||||
}
|
||||
|
||||
class _TurnstileWidgetState extends State<TurnstileWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// On web, use the full API URL
|
||||
// On mobile, Turnstile handles its own endpoints
|
||||
final effectiveBaseUrl = baseUrl ?? ApiConfig.baseUrl;
|
||||
|
||||
// Web: Bypass Turnstile due to package bug with container selector
|
||||
// Backend accepts empty token in dev mode (when TURNSTILE_SECRET is empty)
|
||||
if (kIsWeb) {
|
||||
// Auto-provide empty token to trigger backend bypass
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onToken('BYPASS_DEV_MODE');
|
||||
});
|
||||
return Container(
|
||||
height: 65,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline, size: 16, color: Colors.green),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Security check: Development mode',
|
||||
style: TextStyle(fontSize: 12, color: Colors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile: use normal Turnstile
|
||||
final effectiveBaseUrl = widget.baseUrl ?? ApiConfig.baseUrl;
|
||||
return CloudflareTurnstile(
|
||||
siteKey: siteKey,
|
||||
siteKey: widget.siteKey,
|
||||
baseUrl: effectiveBaseUrl,
|
||||
onTokenReceived: onToken,
|
||||
onTokenReceived: widget.onToken,
|
||||
onError: (error) {
|
||||
if (kDebugMode) {
|
||||
print('Turnstile error: $error');
|
||||
}
|
||||
if (kDebugMode) print('Turnstile error: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
157
sojorn_app/lib/widgets/auth/turnstile_widget_web.dart
Normal file
157
sojorn_app/lib/widgets/auth/turnstile_widget_web.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import 'dart:ui_web' as ui_web;
|
||||
import 'dart:html' as html;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../config/api_config.dart';
|
||||
|
||||
/// Web-compatible Turnstile widget that creates its own HTML container
|
||||
class TurnstileWidget extends StatefulWidget {
|
||||
final String siteKey;
|
||||
final ValueChanged<String> onToken;
|
||||
final String? baseUrl;
|
||||
|
||||
const TurnstileWidget({
|
||||
super.key,
|
||||
required this.siteKey,
|
||||
required this.onToken,
|
||||
this.baseUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TurnstileWidget> createState() => _TurnstileWidgetState();
|
||||
}
|
||||
|
||||
class _TurnstileWidgetState extends State<TurnstileWidget> {
|
||||
String? _token;
|
||||
bool _scriptLoaded = false;
|
||||
bool _rendered = false;
|
||||
late final String _viewId = 'turnstile_${widget.siteKey.hashCode}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
html.DivElement? _container;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (kIsWeb) {
|
||||
_loadTurnstileScript();
|
||||
}
|
||||
}
|
||||
|
||||
void _loadTurnstileScript() {
|
||||
// Check if script already loaded
|
||||
if (html.document.querySelector('script[src*="turnstile"]') != null) {
|
||||
_scriptLoaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final script = html.ScriptElement()
|
||||
..src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
..async = true
|
||||
..defer = true;
|
||||
|
||||
script.onLoad.listen((_) {
|
||||
if (mounted) {
|
||||
setState(() => _scriptLoaded = true);
|
||||
}
|
||||
});
|
||||
|
||||
html.document.head?.append(script);
|
||||
}
|
||||
|
||||
void _renderTurnstile() {
|
||||
if (!kIsWeb || !_scriptLoaded || _rendered) return;
|
||||
|
||||
final turnstile = html.window['turnstile'];
|
||||
if (turnstile == null) return;
|
||||
|
||||
try {
|
||||
turnstile.callMethod('render', [
|
||||
_container,
|
||||
{
|
||||
'sitekey': widget.siteKey,
|
||||
'callback': (String token) {
|
||||
if (mounted) {
|
||||
setState(() => _token = token);
|
||||
widget.onToken(token);
|
||||
}
|
||||
},
|
||||
'theme': 'light',
|
||||
}
|
||||
]);
|
||||
_rendered = true;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Turnstile render error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!kIsWeb) {
|
||||
// On mobile, show a placeholder or use native implementation
|
||||
return Container(
|
||||
height: 65,
|
||||
alignment: Alignment.center,
|
||||
child: const Text('Security verification'),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_scriptLoaded) {
|
||||
return Container(
|
||||
height: 65,
|
||||
alignment: Alignment.center,
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Loading security check...',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Use HtmlElementView for the actual Turnstile
|
||||
return SizedBox(
|
||||
height: 65,
|
||||
child: HtmlElementView(
|
||||
viewType: _viewId,
|
||||
onPlatformViewCreated: (_) {
|
||||
// The container is created in the platform view factory
|
||||
Future.delayed(const Duration(milliseconds: 100), _renderTurnstile);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TurnstileWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (kIsWeb && _scriptLoaded && !_rendered) {
|
||||
Future.delayed(const Duration(milliseconds: 100), _renderTurnstile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the platform view factory for web
|
||||
void registerTurnstileFactory() {
|
||||
if (!kIsWeb) return;
|
||||
|
||||
ui_web.platformViewRegistry.registerViewFactory(
|
||||
'turnstile',
|
||||
(int viewId, {Object? params}) {
|
||||
final div = html.DivElement()
|
||||
..id = 'turnstile-container-$viewId'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%';
|
||||
return div;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -157,10 +157,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1077,14 +1077,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1209,18 +1201,18 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1822,26 +1814,26 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||
sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.26.3"
|
||||
version: "1.29.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.9"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||
sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.12"
|
||||
version: "0.6.15"
|
||||
timeago:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -97,9 +97,6 @@ flutter:
|
|||
- assets/images/toplogo.png
|
||||
- assets/reactions/
|
||||
- assets/reactions/dotto/
|
||||
- assets/reactions/blue/
|
||||
- assets/reactions/green/
|
||||
- assets/reactions/purple/
|
||||
- assets/icon/
|
||||
- assets/rive/
|
||||
- assets/audio/
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@
|
|||
|
||||
<script src="https://www.gstatic.com/firebasejs/10.7.0/firebase-app-compat.js" async></script>
|
||||
<script src="https://www.gstatic.com/firebasejs/10.7.0/firebase-messaging-compat.js" async></script>
|
||||
<script>
|
||||
// Suppress flutter_inappwebview plugin warning on web platform
|
||||
window.flutter_inappwebview_plugin = { ready: Promise.resolve() };
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Shown instantly while Flutter engine + app JS loads -->
|
||||
|
|
|
|||
102
turnstile_service_fixed.go
Normal file
102
turnstile_service_fixed.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TurnstileService struct {
|
||||
secretKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type TurnstileResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCodes []string `json:"error-codes,omitempty"`
|
||||
ChallengeTS string `json:"challenge_ts,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Cdata string `json:"cdata,omitempty"`
|
||||
}
|
||||
|
||||
func NewTurnstileService(secretKey string) *TurnstileService {
|
||||
return &TurnstileService{
|
||||
secretKey: secretKey,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyToken validates a Turnstile token with Cloudflare
|
||||
func (s *TurnstileService) VerifyToken(token, remoteIP string) (*TurnstileResponse, error) {
|
||||
// Allow bypass token for development (Flutter web)
|
||||
if token == "BYPASS_DEV_MODE" {
|
||||
return &TurnstileResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
if s.secretKey == "" {
|
||||
// If no secret key is configured, skip verification (for development)
|
||||
return &TurnstileResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
// Prepare the request data
|
||||
data := fmt.Sprintf(
|
||||
"secret=%s&response=%s",
|
||||
s.secretKey,
|
||||
token,
|
||||
)
|
||||
|
||||
if remoteIP != "" {
|
||||
data += fmt.Sprintf("&remoteip=%s", remoteIP)
|
||||
}
|
||||
|
||||
// Make the request to Cloudflare
|
||||
resp, err := s.client.Post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
"application/x-www-form-urlencoded",
|
||||
bytes.NewBufferString(data),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify turnstile token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read turnstile response: %w", err)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var result TurnstileResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse turnstile response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetErrorMessage returns a user-friendly error message for error codes
|
||||
func (s *TurnstileService) GetErrorMessage(errorCodes []string) string {
|
||||
errorMessages := map[string]string{
|
||||
"missing-input-secret": "Server configuration error",
|
||||
"invalid-input-secret": "Server configuration error",
|
||||
"missing-input-response": "Please complete the security check",
|
||||
"invalid-input-response": "Security check failed, please try again",
|
||||
"bad-request": "Invalid request format",
|
||||
"timeout-or-duplicate": "Security check expired, please try again",
|
||||
"internal-error": "Verification service unavailable",
|
||||
}
|
||||
|
||||
for _, code := range errorCodes {
|
||||
if msg, exists := errorMessages[code]; exists {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return "Security verification failed"
|
||||
}
|
||||
Loading…
Reference in a new issue