feat: Initialize Sojorn Flutter application with core UI, services, E2EE, backup, and build scripts for various platforms.
This commit is contained in:
parent
78f43494a2
commit
10ae2944d2
39
cloud_backup_status.md
Normal file
39
cloud_backup_status.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Cloud Backup Implementation Plan (Complete)
|
||||||
|
|
||||||
|
## 1. Frontend Implementation (Flutter)
|
||||||
|
- [x] **ApiService Updates**: Added `uploadBackup` and `downloadBackup` methods to interact with the backend (endpoints `/backups/upload` and `/backups/download`).
|
||||||
|
- [x] **LocalKeyBackupService Refactor**:
|
||||||
|
- [x] Updated `createEncryptedBackup` to accept `includeKeys` and `includeMessages` flags.
|
||||||
|
- [x] Added `uploadToCloud` method which **defaults to Messages Only** (no keys) for security.
|
||||||
|
- [x] Added `restoreFromCloud` method to fetch and decrypt backups.
|
||||||
|
- [x] **UI Overhaul (LocalBackupScreen)**:
|
||||||
|
- [x] Added "Cloud Mode" vs "Local Mode" toggle.
|
||||||
|
- [x] Implemented "Zero Knowledge" warning UI when Cloud Mode is active (keys excluded by default).
|
||||||
|
- [x] Added visual cues for "Secure Mode".
|
||||||
|
- [x] Integrated `uploadToCloud` and `restoreFromCloud` calls with progress indicators and error handling.
|
||||||
|
|
||||||
|
## 2. Backend Implementation (Go)
|
||||||
|
- [x] **Database Schema**: Created migration `000003_e2ee_backup_recovery.up.sql` for:
|
||||||
|
- `cloud_backups` table (stores encrypted blobs).
|
||||||
|
- `backup_preferences` table.
|
||||||
|
- `user_devices` table.
|
||||||
|
- `sync_codes` table.
|
||||||
|
- `recovery_guardians` and `recovery_sessions` tables (for future social recovery).
|
||||||
|
- [x] **API Endpoints**:
|
||||||
|
- `POST /backups/upload`: Accepts encrypted blob, metadata, and version.
|
||||||
|
- `GET /backups/download`: Retrieves latest backup.
|
||||||
|
- `GET /backups/download/:backup_id`: Retrieves specific backup.
|
||||||
|
- [x] **Data Models**: Defined `CloudBackup`, `UploadBackupRequest`, `DownloadBackupResponse` structs matching frontend expectations.
|
||||||
|
- [x] **Handler Logic**: Implemented "blind storage" logic - backend stores opaque blobs and does not attempt decryption.
|
||||||
|
|
||||||
|
## 3. Deployment Status (Pending)
|
||||||
|
- [x] **Compilation**: Successfully compiled `sojorn-api-linux` and `migrate-linux` binaries locally.
|
||||||
|
- [ ] **Upload**: Failed to upload binaries to VPS (`194.238.28.122`) due to SSH authentication failure ("Permission denied") with provided credentials.
|
||||||
|
- [ ] **Migration**: Database migration failed from local machine due to port 5432 being closed/filtered. Needs to be run from the VPS.
|
||||||
|
- [ ] **Restart**: Service restart pending successful SSH access.
|
||||||
|
|
||||||
|
## 4. Next Steps
|
||||||
|
Once SSH access is restored (verify password or add public key):
|
||||||
|
1. **Upload Binaries**: `scp sojorn-api-linux migrate-linux root@194.238.28.122:/root/`
|
||||||
|
2. **Run Migration**: `ssh root@... "./migrate-linux -path ... up"`
|
||||||
|
3. **Restart Service**: `ssh root@... "systemctl restart sojorn-api"`
|
||||||
35
feed_reactions_fix.md
Normal file
35
feed_reactions_fix.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Feed Reactions Fix - Implementation Summary
|
||||||
|
|
||||||
|
## Status: COMPLETE & DEPLOYED ✅
|
||||||
|
|
||||||
|
### 1. Issue Description
|
||||||
|
- **Problem**: Reactions were not displaying on the "Following" (Home) and "Profile" feeds. The "Postcard" UI (specifically the compact method) was defaulting to the "Add Reaction" button because no reaction data was being returned by the API for these lists.
|
||||||
|
- **Root Cause**: The SQL queries for `GetFeed` and `GetPostsByAuthor` in the backend were not aggregating reaction data (counts and user choices), unlike the single-post endpoint.
|
||||||
|
|
||||||
|
### 2. Implementation Details (Backend)
|
||||||
|
- **File**: `internal/repository/post_repository.go`
|
||||||
|
- **Changes**:
|
||||||
|
- Modified `GetFeed` SQL query to include a correlated subquery fetching `jsonb_object_agg(emoji, count)` for `reaction_counts`.
|
||||||
|
- Modified `GetPostsByAuthor` SQL query to do the same.
|
||||||
|
- Added logic to fetch `my_reactions` (the current user's votes) for both feeds.
|
||||||
|
- Updated the Go `Scan` destinations to populate the `Reactions` and `MyReactions` fields in the `Post` model.
|
||||||
|
- **Correction**: Also fixed a missing `allow_chain` and `visibility` selection in the `GetPostsByAuthor` query, ensuring consistency across the app.
|
||||||
|
|
||||||
|
### 3. Frontend logic (Verified)
|
||||||
|
- **Widget**: `sojornPostCard` -> `PostActions` -> `ReactionsDisplay`
|
||||||
|
- **Logic**: The `ReactionsDisplay` widget in `compact` mode (used in feeds) is designed to:
|
||||||
|
1. Show the user's reaction if they voted.
|
||||||
|
2. Else, show the "Most Used" reaction from the community.
|
||||||
|
3. Else (if valid data but count is 0), show the "Add" button.
|
||||||
|
- **Result**: Now that the backend provides the data (Case 1 or 2), the UI will correctly display the reaction chips instead of just the "+" button.
|
||||||
|
|
||||||
|
### 4. Deployment
|
||||||
|
- **Server**: `194.238.28.122`
|
||||||
|
- **Action**:
|
||||||
|
- Compiled `sojorn-api-linux` locally.
|
||||||
|
- Uploaded to `/opt/sojorn/bin/api` via `scp` (user: `patrick`).
|
||||||
|
- Restarted `sojorn-api` service via `systemctl`.
|
||||||
|
- **Status**: API is active and serving the new queries.
|
||||||
|
|
||||||
|
### 5. Next Steps
|
||||||
|
- **User Action**: Pull-to-refresh the feed in the Sojorn app to see the changes.
|
||||||
|
|
@ -26,7 +26,7 @@ Get-Content $EnvPath | ForEach-Object {
|
||||||
$values[$key] = $value
|
$values[$key] = $value
|
||||||
}
|
}
|
||||||
|
|
||||||
$required = @('SUPABASE_URL', 'SUPABASE_ANON_KEY', 'API_BASE_URL')
|
$required = @('API_BASE_URL')
|
||||||
$missing = $required | Where-Object {
|
$missing = $required | Where-Object {
|
||||||
-not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_])
|
-not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_])
|
||||||
}
|
}
|
||||||
|
|
@ -37,8 +37,6 @@ if ($missing.Count -gt 0) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$defineArgs = @(
|
$defineArgs = @(
|
||||||
"--dart-define=SUPABASE_URL=$($values['SUPABASE_URL'])",
|
|
||||||
"--dart-define=SUPABASE_ANON_KEY=$($values['SUPABASE_ANON_KEY'])",
|
|
||||||
"--dart-define=API_BASE_URL=$($values['API_BASE_URL'])"
|
"--dart-define=API_BASE_URL=$($values['API_BASE_URL'])"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
16
run_web.ps1
16
run_web.ps1
|
|
@ -1,6 +1,6 @@
|
||||||
param(
|
param(
|
||||||
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
||||||
[int]$Port = 8000,
|
[int]$Port = 8001,
|
||||||
[string]$Renderer = "auto", # Options: auto, canvaskit, html
|
[string]$Renderer = "auto", # Options: auto, canvaskit, html
|
||||||
[switch]$NoWasmDryRun
|
[switch]$NoWasmDryRun
|
||||||
)
|
)
|
||||||
|
|
@ -8,7 +8,9 @@ param(
|
||||||
function Parse-Env($path) {
|
function Parse-Env($path) {
|
||||||
$vals = @{}
|
$vals = @{}
|
||||||
if (-not (Test-Path $path)) {
|
if (-not (Test-Path $path)) {
|
||||||
Write-Host "No .env file found at ${path}. Falling back to defaults." -ForegroundColor Yellow
|
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.gosojorn.com/api/v1'
|
||||||
return $vals
|
return $vals
|
||||||
}
|
}
|
||||||
Get-Content $path | ForEach-Object {
|
Get-Content $path | ForEach-Object {
|
||||||
|
|
@ -37,16 +39,20 @@ foreach ($k in $keysOfInterest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ensure API_BASE_URL always points to the public https endpoint.
|
# Ensure API_BASE_URL is set
|
||||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||||
$defineArgs += "--dart-define=API_BASE_URL=https://api.gosojorn.com/api/v1"
|
|
||||||
$currentApi = 'https://api.gosojorn.com/api/v1'
|
$currentApi = 'https://api.gosojorn.com/api/v1'
|
||||||
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
} else {
|
} else {
|
||||||
$currentApi = $values['API_BASE_URL']
|
$currentApi = $values['API_BASE_URL']
|
||||||
|
# Always ensure we're using the HTTPS endpoint
|
||||||
if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) {
|
if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) {
|
||||||
$currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com')
|
$currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com')
|
||||||
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
||||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
|
} elseif ($currentApi.StartsWith('http://localhost:')) {
|
||||||
|
# For local development, keep localhost but warn
|
||||||
|
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +66,7 @@ try {
|
||||||
$cmdArgs = @(
|
$cmdArgs = @(
|
||||||
'run',
|
'run',
|
||||||
'-d',
|
'-d',
|
||||||
'chrome',
|
'edge',
|
||||||
'--web-hostname',
|
'--web-hostname',
|
||||||
'localhost',
|
'localhost',
|
||||||
'--web-port',
|
'--web-port',
|
||||||
|
|
|
||||||
86
run_web_chrome.ps1
Normal file
86
run_web_chrome.ps1
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
param(
|
||||||
|
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
||||||
|
[int]$Port = 8002,
|
||||||
|
[string]$Renderer = "auto", # Options: auto, canvaskit, html
|
||||||
|
[switch]$NoWasmDryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
function Parse-Env($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.gosojorn.com/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 = Parse-Env $EnvPath
|
||||||
|
|
||||||
|
# Collect dart-defines we actually use on web.
|
||||||
|
$defineArgs = @()
|
||||||
|
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_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.gosojorn.com/api/v1'
|
||||||
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
|
} else {
|
||||||
|
$currentApi = $values['API_BASE_URL']
|
||||||
|
# Always ensure we're using the HTTPS endpoint
|
||||||
|
if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) {
|
||||||
|
$currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com')
|
||||||
|
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
||||||
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
|
} elseif ($currentApi.StartsWith('http://localhost:')) {
|
||||||
|
# For local development, keep localhost but warn
|
||||||
|
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Launching Sojorn Web (Chrome)..." -ForegroundColor Cyan
|
||||||
|
Write-Host "Port: $Port"
|
||||||
|
Write-Host "Renderer: $Renderer"
|
||||||
|
Write-Host "API: $currentApi"
|
||||||
|
|
||||||
|
Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
||||||
|
try {
|
||||||
|
$cmdArgs = @(
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'chrome',
|
||||||
|
'--web-hostname',
|
||||||
|
'localhost',
|
||||||
|
'--web-port',
|
||||||
|
"$Port"
|
||||||
|
)
|
||||||
|
$cmdArgs += $defineArgs
|
||||||
|
|
||||||
|
if ($NoWasmDryRun) {
|
||||||
|
$cmdArgs += '--no-wasm-dry-run'
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||||
|
& flutter $cmdArgs
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
111
run_windows.ps1
Normal file
111
run_windows.ps1
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
param(
|
||||||
|
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
||||||
|
[switch]$Release,
|
||||||
|
[switch]$Clean
|
||||||
|
)
|
||||||
|
|
||||||
|
function Parse-Env($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.gosojorn.com/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 = Parse-Env $EnvPath
|
||||||
|
|
||||||
|
# Collect dart-defines we actually use on Windows.
|
||||||
|
$defineArgs = @()
|
||||||
|
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_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.gosojorn.com/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 (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
||||||
|
try {
|
||||||
|
# Clean build if requested
|
||||||
|
if ($Clean) {
|
||||||
|
Write-Host "Cleaning build cache..." -ForegroundColor Yellow
|
||||||
|
& flutter clean
|
||||||
|
& flutter 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
|
||||||
|
& flutter $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
|
||||||
|
}
|
||||||
34
run_windows_app.ps1
Normal file
34
run_windows_app.ps1
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
param(
|
||||||
|
[string]$BuildType = "Release", # Release or Debug
|
||||||
|
[switch]$BuildFirst
|
||||||
|
)
|
||||||
|
|
||||||
|
$buildPath = Join-Path $PSScriptRoot "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
|
||||||
|
|
@ -1,11 +1,3 @@
|
||||||
# Dotto Emoji Set
|
# Dotto Emoji Set
|
||||||
|
|
||||||
**Source:** [Dotto Emoji](https://github.com/meritite-union/dotto-emoji) by meritite-union
|
**Source:** [Dotto Emoji](https://github.com/meritite-union/dotto-emoji) by meritite-union
|
||||||
|
|
||||||
**Description:** 16x16 pixel art emoji set with a retro aesthetic
|
|
||||||
|
|
||||||
**License:** Custom license - see repository for details
|
|
||||||
|
|
||||||
**Format:** Scalable Vector Graphics (SVG)
|
|
||||||
|
|
||||||
**Style:** Pixel art with clean, minimalist design
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Download Dotto Emoji SVG files from GitHub repository
|
|
||||||
"""
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
# Mapping of emoji names to GitHub SVG files
|
|
||||||
EMOJI_MAPPING = {
|
|
||||||
'heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji0.svg',
|
|
||||||
'thumbs_up.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji1.svg',
|
|
||||||
'laughing_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/face_with_tears_of_joy.svg',
|
|
||||||
'face_with_open_mouth.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji2.svg',
|
|
||||||
'sad_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji3.svg',
|
|
||||||
'angry_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji4.svg',
|
|
||||||
'party_popper.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji5.svg',
|
|
||||||
'fire.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji6.svg',
|
|
||||||
'clapping_hands.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji7.svg',
|
|
||||||
'folded_hands.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji8.svg',
|
|
||||||
'hundred_points.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji9.svg',
|
|
||||||
'thinking_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji10.svg',
|
|
||||||
'beaming_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/beaming_face_with_smiling_eyes.svg',
|
|
||||||
'face_with_tears.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/face_with_tears_of_joy.svg',
|
|
||||||
'grinning_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/grinning_face.svg',
|
|
||||||
'smiling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_smiling_eyes.svg',
|
|
||||||
'winking_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/winking_face.svg',
|
|
||||||
'melting_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/melting_face.svg',
|
|
||||||
'upside_down_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/upside-down_face.svg',
|
|
||||||
'rolling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/rolling_on_the_floor_laughing.svg',
|
|
||||||
'slightly_smiling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/slightly_smiling_face.svg',
|
|
||||||
'smiling_face_with_halo.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_halo.svg',
|
|
||||||
'smiling_face_with_hearts.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_hearts.svg',
|
|
||||||
'face_with_monocle.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji65.svg',
|
|
||||||
'nerd_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji66.svg',
|
|
||||||
'party_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji67.svg',
|
|
||||||
'sunglasses_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji68.svg',
|
|
||||||
'disappointed_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji69.svg',
|
|
||||||
'worried_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji70.svg',
|
|
||||||
'anguished_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji71.svg',
|
|
||||||
'fearful_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji72.svg',
|
|
||||||
'downcast_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji73.svg',
|
|
||||||
'loudly_crying_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji74.svg',
|
|
||||||
'skull.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji75.svg',
|
|
||||||
'ghost.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji76.svg',
|
|
||||||
'robot_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji77.svg',
|
|
||||||
'heart_with_arrow.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji78.svg',
|
|
||||||
'broken_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji79.svg',
|
|
||||||
'sparkling_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji80.svg',
|
|
||||||
'green_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji81.svg',
|
|
||||||
'blue_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji82.svg',
|
|
||||||
'purple_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji83.svg',
|
|
||||||
}
|
|
||||||
|
|
||||||
def download_file(url, local_path):
|
|
||||||
"""Download a file from URL to local path"""
|
|
||||||
try:
|
|
||||||
response = requests.get(url, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
with open(local_path, 'wb') as f:
|
|
||||||
f.write(response.content)
|
|
||||||
|
|
||||||
print(f"✓ Downloaded {os.path.basename(local_path)}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Failed to download {os.path.basename(local_path)}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main function to download all emoji files"""
|
|
||||||
print("Downloading Dotto Emoji SVG files...")
|
|
||||||
|
|
||||||
# Create directory if it doesn't exist
|
|
||||||
os.makedirs('.', exist_ok=True)
|
|
||||||
|
|
||||||
success_count = 0
|
|
||||||
total_count = len(EMOJI_MAPPING)
|
|
||||||
|
|
||||||
for filename, url in EMOJI_MAPPING.items():
|
|
||||||
local_path = filename
|
|
||||||
if download_file(url, local_path):
|
|
||||||
success_count += 1
|
|
||||||
|
|
||||||
print(f"\nDownload complete: {success_count}/{total_count} files downloaded")
|
|
||||||
|
|
||||||
if success_count > 0:
|
|
||||||
print("\nThe Dotto emoji set is now ready to use!")
|
|
||||||
print("You can now run 'flutter pub get' to install dependencies.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -330,12 +330,16 @@ class PostPreview {
|
||||||
final String body;
|
final String body;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final Profile? author;
|
final Profile? author;
|
||||||
|
final Map<String, int>? reactions;
|
||||||
|
final List<String>? myReactions;
|
||||||
|
|
||||||
const PostPreview({
|
const PostPreview({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.body,
|
required this.body,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.author,
|
this.author,
|
||||||
|
this.reactions,
|
||||||
|
this.myReactions,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory PostPreview.fromJson(Map<String, dynamic> json) {
|
factory PostPreview.fromJson(Map<String, dynamic> json) {
|
||||||
|
|
@ -345,6 +349,8 @@ class PostPreview {
|
||||||
body: json['body'] as String? ?? '',
|
body: json['body'] as String? ?? '',
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
author: authorJson != null ? Profile.fromJson(authorJson) : null,
|
author: authorJson != null ? Profile.fromJson(authorJson) : null,
|
||||||
|
reactions: Post._parseReactions(json['reactions'] ?? json['reaction_counts']),
|
||||||
|
myReactions: Post._parseReactionsList(json['my_reactions'] ?? json['myReactions']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,6 +360,8 @@ class PostPreview {
|
||||||
body: post.body,
|
body: post.body,
|
||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
author: post.author,
|
author: post.author,
|
||||||
|
reactions: post.reactions,
|
||||||
|
myReactions: post.myReactions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,6 +371,8 @@ class PostPreview {
|
||||||
'body': body,
|
'body': body,
|
||||||
'created_at': createdAt.toIso8601String(),
|
'created_at': createdAt.toIso8601String(),
|
||||||
'author': author?.toJson(),
|
'author': author?.toJson(),
|
||||||
|
'reactions': reactions,
|
||||||
|
'my_reactions': myReactions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
|
|
||||||
// Terms and privacy note
|
// Terms and privacy note
|
||||||
Text(
|
Text(
|
||||||
'By continuing, you agree to our vibrant community guidelines', // Updated text
|
'By continuing, you agree to our vibrant community guidelines.\nA product of MPLS LLC.', // Updated text
|
||||||
style: AppTheme.textTheme.labelSmall?.copyWith(
|
style: AppTheme.textTheme.labelSmall?.copyWith(
|
||||||
// Replaced AppTheme.bodySmall
|
// Replaced AppTheme.bodySmall
|
||||||
color: AppTheme
|
color: AppTheme
|
||||||
|
|
|
||||||
|
|
@ -169,14 +169,10 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
|
|
||||||
PreferredSizeWidget _buildAppBar() {
|
PreferredSizeWidget _buildAppBar() {
|
||||||
return AppBar(
|
return AppBar(
|
||||||
title: Text(
|
title: Image.asset(
|
||||||
'sojorn',
|
'assets/images/toplogo.png',
|
||||||
style: GoogleFonts.literata(
|
height: 38,
|
||||||
fontSize: 28,
|
fit: BoxFit.contain,
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
color: AppTheme.navyBlue,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:timeago/timeago.dart' as timeago;
|
import 'package:timeago/timeago.dart' as timeago;
|
||||||
import '../../widgets/reactions/reaction_picker.dart';
|
import '../../widgets/reactions/reaction_picker.dart';
|
||||||
import '../../widgets/reactions/reaction_strip.dart';
|
import '../../widgets/reactions/reactions_display.dart';
|
||||||
import '../../models/post.dart';
|
import '../../models/post.dart';
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
|
@ -387,6 +387,15 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ReactionsDisplay(
|
||||||
|
reactionCounts: _reactionCountsFor(parentPost),
|
||||||
|
myReactions: _myReactionsFor(parentPost),
|
||||||
|
onToggleReaction: (emoji) => _toggleReaction(parentPost.id, emoji),
|
||||||
|
onAddReaction: () => _openReactionPicker(parentPost.id),
|
||||||
|
mode: ReactionsDisplayMode.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -552,12 +561,13 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Reactions section - full width like main post
|
// Reactions section - full width like main post
|
||||||
ReactionStrip(
|
ReactionsDisplay(
|
||||||
reactions: _reactionCountsFor(post),
|
reactionCounts: _reactionCountsFor(post),
|
||||||
myReactions: _myReactionsFor(post),
|
myReactions: _myReactionsFor(post),
|
||||||
reactionUsers: _reactionUsersFor(post),
|
reactionUsers: _reactionUsersFor(post),
|
||||||
onToggle: (emoji) => _toggleReaction(post.id, emoji),
|
onToggleReaction: (emoji) => _toggleReaction(post.id, emoji),
|
||||||
onAdd: () => _openReactionPicker(post.id),
|
onAddReaction: () => _openReactionPicker(post.id),
|
||||||
|
mode: ReactionsDisplayMode.full,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Actions row - left aligned
|
// Actions row - left aligned
|
||||||
|
|
@ -869,21 +879,20 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Compact reactions display
|
// Compact reactions display
|
||||||
if (_reactionCountsFor(post).isNotEmpty) ...[
|
Expanded(
|
||||||
Icon(
|
child: SingleChildScrollView(
|
||||||
Icons.favorite_border,
|
scrollDirection: Axis.horizontal,
|
||||||
size: 12,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
color: AppTheme.textSecondary,
|
child: ReactionsDisplay(
|
||||||
),
|
reactionCounts: _reactionCountsFor(post),
|
||||||
const SizedBox(width: 4),
|
myReactions: _myReactionsFor(post),
|
||||||
Text(
|
onToggleReaction: (emoji) => _toggleReaction(post.id, emoji),
|
||||||
'${_reactionCountsFor(post).values.reduce((a, b) => a + b)}',
|
onAddReaction: () => _openReactionPicker(post.id),
|
||||||
style: GoogleFonts.inter(
|
mode: ReactionsDisplayMode.compact,
|
||||||
color: AppTheme.textSecondary,
|
padding: EdgeInsets.zero,
|
||||||
fontSize: 10,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import '../compose/compose_screen.dart';
|
||||||
import '../post/post_detail_screen.dart';
|
import '../post/post_detail_screen.dart';
|
||||||
import 'profile_settings_screen.dart';
|
import 'profile_settings_screen.dart';
|
||||||
|
|
||||||
enum ProfileFeedType { posts, appreciated, saved, chained }
|
enum ProfileFeedType { posts, saved, chained }
|
||||||
|
|
||||||
/// Premium profile screen with NestedScrollView and SliverAppBar
|
/// Premium profile screen with NestedScrollView and SliverAppBar
|
||||||
class ProfileScreen extends ConsumerStatefulWidget {
|
class ProfileScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -55,11 +55,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
bool _hasMorePosts = true;
|
bool _hasMorePosts = true;
|
||||||
String? _postsError;
|
String? _postsError;
|
||||||
|
|
||||||
List<Post> _appreciatedPosts = [];
|
|
||||||
bool _isAppreciatedLoading = false;
|
|
||||||
bool _isAppreciatedLoadingMore = false;
|
|
||||||
bool _hasMoreAppreciated = true;
|
|
||||||
String? _appreciatedError;
|
|
||||||
|
|
||||||
List<Post> _savedPosts = [];
|
List<Post> _savedPosts = [];
|
||||||
bool _isSavedLoading = false;
|
bool _isSavedLoading = false;
|
||||||
|
|
@ -76,7 +72,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 4, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
_tabController.addListener(() {
|
_tabController.addListener(() {
|
||||||
if (!_tabController.indexIsChanging) {
|
if (!_tabController.indexIsChanging) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -231,8 +227,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
switch (_activeFeed) {
|
switch (_activeFeed) {
|
||||||
case ProfileFeedType.posts:
|
case ProfileFeedType.posts:
|
||||||
return _loadPosts(refresh: refresh);
|
return _loadPosts(refresh: refresh);
|
||||||
case ProfileFeedType.appreciated:
|
|
||||||
return _loadAppreciated(refresh: refresh);
|
|
||||||
case ProfileFeedType.saved:
|
case ProfileFeedType.saved:
|
||||||
return _loadSaved(refresh: refresh);
|
return _loadSaved(refresh: refresh);
|
||||||
case ProfileFeedType.chained:
|
case ProfileFeedType.chained:
|
||||||
|
|
@ -297,62 +291,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAppreciated({bool refresh = false}) async {
|
|
||||||
if (_profile == null) return;
|
|
||||||
|
|
||||||
if (refresh) {
|
|
||||||
setState(() {
|
|
||||||
_appreciatedPosts = [];
|
|
||||||
_hasMoreAppreciated = true;
|
|
||||||
_appreciatedError = null;
|
|
||||||
});
|
|
||||||
} else if (!_hasMoreAppreciated || _isAppreciatedLoadingMore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
if (refresh) {
|
|
||||||
_isAppreciatedLoading = true;
|
|
||||||
} else {
|
|
||||||
_isAppreciatedLoadingMore = true;
|
|
||||||
}
|
|
||||||
if (!refresh) {
|
|
||||||
_appreciatedError = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final apiService = ref.read(apiServiceProvider);
|
|
||||||
final posts = await apiService.getAppreciatedPosts(
|
|
||||||
userId: _profile!.id,
|
|
||||||
limit: _postsPageSize,
|
|
||||||
offset: refresh ? 0 : _appreciatedPosts.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
if (refresh) {
|
|
||||||
_appreciatedPosts = posts;
|
|
||||||
} else {
|
|
||||||
_appreciatedPosts.addAll(posts);
|
|
||||||
}
|
|
||||||
_hasMoreAppreciated = posts.length == _postsPageSize;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_appreciatedError = error.toString().replaceAll('Exception: ', '');
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isAppreciatedLoading = false;
|
|
||||||
_isAppreciatedLoadingMore = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadSaved({bool refresh = false}) async {
|
Future<void> _loadSaved({bool refresh = false}) async {
|
||||||
if (_profile == null) return;
|
if (_profile == null) return;
|
||||||
|
|
@ -754,8 +693,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ProfileFeedType.posts:
|
case ProfileFeedType.posts:
|
||||||
return _posts;
|
return _posts;
|
||||||
case ProfileFeedType.appreciated:
|
|
||||||
return _appreciatedPosts;
|
|
||||||
case ProfileFeedType.saved:
|
case ProfileFeedType.saved:
|
||||||
return _savedPosts;
|
return _savedPosts;
|
||||||
case ProfileFeedType.chained:
|
case ProfileFeedType.chained:
|
||||||
|
|
@ -767,8 +704,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ProfileFeedType.posts:
|
case ProfileFeedType.posts:
|
||||||
return _isPostsLoading;
|
return _isPostsLoading;
|
||||||
case ProfileFeedType.appreciated:
|
|
||||||
return _isAppreciatedLoading;
|
|
||||||
case ProfileFeedType.saved:
|
case ProfileFeedType.saved:
|
||||||
return _isSavedLoading;
|
return _isSavedLoading;
|
||||||
case ProfileFeedType.chained:
|
case ProfileFeedType.chained:
|
||||||
|
|
@ -780,8 +715,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ProfileFeedType.posts:
|
case ProfileFeedType.posts:
|
||||||
return _isPostsLoadingMore;
|
return _isPostsLoadingMore;
|
||||||
case ProfileFeedType.appreciated:
|
|
||||||
return _isAppreciatedLoadingMore;
|
|
||||||
case ProfileFeedType.saved:
|
case ProfileFeedType.saved:
|
||||||
return _isSavedLoadingMore;
|
return _isSavedLoadingMore;
|
||||||
case ProfileFeedType.chained:
|
case ProfileFeedType.chained:
|
||||||
|
|
@ -793,8 +726,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ProfileFeedType.posts:
|
case ProfileFeedType.posts:
|
||||||
return _hasMorePosts;
|
return _hasMorePosts;
|
||||||
case ProfileFeedType.appreciated:
|
|
||||||
return _hasMoreAppreciated;
|
|
||||||
case ProfileFeedType.saved:
|
case ProfileFeedType.saved:
|
||||||
return _hasMoreSaved;
|
return _hasMoreSaved;
|
||||||
case ProfileFeedType.chained:
|
case ProfileFeedType.chained:
|
||||||
|
|
@ -806,8 +737,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ProfileFeedType.posts:
|
case ProfileFeedType.posts:
|
||||||
return _postsError;
|
return _postsError;
|
||||||
case ProfileFeedType.appreciated:
|
|
||||||
return _appreciatedError;
|
|
||||||
case ProfileFeedType.saved:
|
case ProfileFeedType.saved:
|
||||||
return _savedError;
|
return _savedError;
|
||||||
case ProfileFeedType.chained:
|
case ProfileFeedType.chained:
|
||||||
|
|
@ -815,6 +744,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final profile = _profile;
|
final profile = _profile;
|
||||||
|
|
@ -910,7 +840,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
labelStyle: AppTheme.labelMedium,
|
labelStyle: AppTheme.labelMedium,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Posts'),
|
Tab(text: 'Posts'),
|
||||||
Tab(text: 'Appreciated'),
|
|
||||||
Tab(text: 'Saved'),
|
Tab(text: 'Saved'),
|
||||||
Tab(text: 'Chains'),
|
Tab(text: 'Chains'),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,6 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppTheme.spacingLg),
|
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
|
|
@ -346,6 +345,34 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2))
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
: const Text('Save'))),
|
: const Text('Save'))),
|
||||||
|
const SizedBox(height: AppTheme.spacingLg * 2), // Fixed spacing2xl error
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Sojorn',
|
||||||
|
style: AppTheme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: AppTheme.navyText.withOpacity(0.5),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'A product of MPLS LLC',
|
||||||
|
style: AppTheme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: AppTheme.navyText.withOpacity(0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'© ${DateTime.now().year} All rights reserved',
|
||||||
|
style: AppTheme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: AppTheme.navyText.withOpacity(0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppTheme.spacingLg),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -64,15 +64,28 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
||||||
bool _hasMorePosts = true;
|
bool _hasMorePosts = true;
|
||||||
String? _postsError;
|
String? _postsError;
|
||||||
|
|
||||||
|
List<Post> _savedPosts = [];
|
||||||
|
bool _isSavedLoading = false;
|
||||||
|
bool _isSavedLoadingMore = false;
|
||||||
|
bool _hasMoreSaved = true;
|
||||||
|
String? _savedError;
|
||||||
|
|
||||||
|
List<Post> _chainedPosts = [];
|
||||||
|
bool _isChainedLoading = false;
|
||||||
|
bool _isChainedLoadingMore = false;
|
||||||
|
bool _hasMoreChained = true;
|
||||||
|
String? _chainedError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 4, vsync: this);
|
||||||
_tabController.addListener(() {
|
_tabController.addListener(() {
|
||||||
if (!_tabController.indexIsChanging) {
|
if (!_tabController.indexIsChanging) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_activeTab = _tabController.index;
|
_activeTab = _tabController.index;
|
||||||
});
|
});
|
||||||
|
_loadActiveFeed();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -136,6 +149,20 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadActiveFeed() async {
|
||||||
|
switch (_activeTab) {
|
||||||
|
case 0:
|
||||||
|
if (_posts.isEmpty) _loadPosts(refresh: true);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
if (_savedPosts.isEmpty) _loadSaved(refresh: true);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
if (_chainedPosts.isEmpty) _loadChained(refresh: true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadPosts({bool refresh = false}) async {
|
Future<void> _loadPosts({bool refresh = false}) async {
|
||||||
if (_profile == null) return;
|
if (_profile == null) return;
|
||||||
|
|
||||||
|
|
@ -190,6 +217,121 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSaved({bool refresh = false}) async {
|
||||||
|
if (_profile == null) return;
|
||||||
|
|
||||||
|
if (refresh) {
|
||||||
|
setState(() {
|
||||||
|
_savedPosts = [];
|
||||||
|
_hasMoreSaved = true;
|
||||||
|
_savedError = null;
|
||||||
|
});
|
||||||
|
} else if (!_hasMoreSaved || _isSavedLoadingMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (refresh) {
|
||||||
|
_isSavedLoading = true;
|
||||||
|
} else {
|
||||||
|
_isSavedLoadingMore = true;
|
||||||
|
}
|
||||||
|
if (!refresh) {
|
||||||
|
_savedError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final apiService = ref.read(apiServiceProvider);
|
||||||
|
// NOTE: This will only return posts if the backend/permission allows.
|
||||||
|
final posts = await apiService.getSavedPosts(
|
||||||
|
userId: _profile!.id,
|
||||||
|
limit: _postsPageSize,
|
||||||
|
offset: refresh ? 0 : _savedPosts.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (refresh) {
|
||||||
|
_savedPosts = posts;
|
||||||
|
} else {
|
||||||
|
_savedPosts.addAll(posts);
|
||||||
|
}
|
||||||
|
_hasMoreSaved = posts.length == _postsPageSize;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_savedError = error.toString().replaceAll('Exception: ', '');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSavedLoading = false;
|
||||||
|
_isSavedLoadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadChained({bool refresh = false}) async {
|
||||||
|
if (_profile == null) return;
|
||||||
|
|
||||||
|
if (refresh) {
|
||||||
|
setState(() {
|
||||||
|
_chainedPosts = [];
|
||||||
|
_hasMoreChained = true;
|
||||||
|
_chainedError = null;
|
||||||
|
});
|
||||||
|
} else if (!_hasMoreChained || _isChainedLoadingMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (refresh) {
|
||||||
|
_isChainedLoading = true;
|
||||||
|
} else {
|
||||||
|
_isChainedLoadingMore = true;
|
||||||
|
}
|
||||||
|
if (!refresh) {
|
||||||
|
_chainedError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final apiService = ref.read(apiServiceProvider);
|
||||||
|
final posts = await apiService.getChainedPostsForAuthor(
|
||||||
|
authorId: _profile!.id,
|
||||||
|
limit: _postsPageSize,
|
||||||
|
offset: refresh ? 0 : _chainedPosts.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (refresh) {
|
||||||
|
_chainedPosts = posts;
|
||||||
|
} else {
|
||||||
|
_chainedPosts.addAll(posts);
|
||||||
|
}
|
||||||
|
_hasMoreChained = posts.length == _postsPageSize;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_chainedError = error.toString().replaceAll('Exception: ', '');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isChainedLoading = false;
|
||||||
|
_isChainedLoadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadPrivacySettings() async {
|
Future<void> _loadPrivacySettings() async {
|
||||||
if (_isPrivacyLoading) return;
|
if (_isPrivacyLoading) return;
|
||||||
|
|
||||||
|
|
@ -616,6 +758,8 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
||||||
labelStyle: AppTheme.labelMedium,
|
labelStyle: AppTheme.labelMedium,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Posts'),
|
Tab(text: 'Posts'),
|
||||||
|
Tab(text: 'Saved'),
|
||||||
|
Tab(text: 'Chains'),
|
||||||
Tab(text: 'About'),
|
Tab(text: 'About'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -624,33 +768,77 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTabBarView() {
|
Widget _buildTabBarView() {
|
||||||
if (_activeTab == 1) {
|
if (_activeTab == 3) {
|
||||||
return _buildAboutTab();
|
return _buildAboutTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_activeTab == 1) {
|
||||||
|
return _buildFeedView(
|
||||||
|
_savedPosts,
|
||||||
|
_isSavedLoading,
|
||||||
|
_isSavedLoadingMore,
|
||||||
|
_hasMoreSaved,
|
||||||
|
_savedError,
|
||||||
|
() => _loadSaved(refresh: true),
|
||||||
|
() => _loadSaved(refresh: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeTab == 2) {
|
||||||
|
return _buildFeedView(
|
||||||
|
_chainedPosts,
|
||||||
|
_isChainedLoading,
|
||||||
|
_isChainedLoadingMore,
|
||||||
|
_hasMoreChained,
|
||||||
|
_chainedError,
|
||||||
|
() => _loadChained(refresh: true),
|
||||||
|
() => _loadChained(refresh: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildFeedView(
|
||||||
|
_posts,
|
||||||
|
_isPostsLoading,
|
||||||
|
_isPostsLoadingMore,
|
||||||
|
_hasMorePosts,
|
||||||
|
_postsError,
|
||||||
|
() => _loadPosts(refresh: true),
|
||||||
|
() => _loadPosts(refresh: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFeedView(
|
||||||
|
List<Post> posts,
|
||||||
|
bool isLoading,
|
||||||
|
bool isLoadingMore,
|
||||||
|
bool hasMore,
|
||||||
|
String? error,
|
||||||
|
VoidCallback onRefresh,
|
||||||
|
VoidCallback onLoadMore,
|
||||||
|
) {
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => _loadPosts(refresh: true),
|
onRefresh: () async => onRefresh(),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
if (_postsError != null)
|
if (error != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||||
child: Text(
|
child: Text(
|
||||||
_postsError!,
|
error,
|
||||||
style: AppTheme.bodyMedium.copyWith(color: AppTheme.error),
|
style: AppTheme.bodyMedium.copyWith(color: AppTheme.error),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isPostsLoading && _posts.isEmpty)
|
if (isLoading && posts.isEmpty)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
|
padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_posts.isEmpty && !_isPostsLoading)
|
if (posts.isEmpty && !isLoading)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -661,7 +849,7 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_posts.isNotEmpty)
|
if (posts.isNotEmpty)
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppTheme.spacingMd,
|
horizontal: AppTheme.spacingMd,
|
||||||
|
|
@ -670,10 +858,10 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
final post = _posts[index];
|
final post = posts[index];
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: index == _posts.length - 1
|
bottom: index == posts.length - 1
|
||||||
? 0
|
? 0
|
||||||
: AppTheme.spacingSm,
|
: AppTheme.spacingSm,
|
||||||
),
|
),
|
||||||
|
|
@ -684,22 +872,22 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: _posts.length,
|
childCount: posts.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isPostsLoadingMore)
|
if (isLoadingMore)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
|
padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_isPostsLoadingMore && _hasMorePosts && _posts.isNotEmpty)
|
if (!isLoadingMore && hasMore && posts.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () => _loadPosts(refresh: false),
|
onPressed: onLoadMore,
|
||||||
child: const Text('Load more'),
|
child: const Text('Load more'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -425,6 +425,7 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onDelete: () => _confirmDeleteConversation(conversation),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -432,133 +433,247 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeleteConversation(SecureConversation conversation) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: AppTheme.cardSurface,
|
||||||
|
title: Text(
|
||||||
|
'Delete Conversation?',
|
||||||
|
style: GoogleFonts.literata(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.navyBlue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
'Are you sure you want to delete this conversation with ${conversation.otherUserDisplayName ?? conversation.otherUserHandle}? This will remove all messages for everyone.',
|
||||||
|
style: GoogleFonts.inter(color: AppTheme.navyText),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(
|
||||||
|
'Cancel',
|
||||||
|
style: GoogleFonts.inter(color: AppTheme.egyptianBlue),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.error,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
try {
|
||||||
|
await _chatService.deleteConversation(conversation.id, fullDelete: true);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Conversation deleted'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_loadConversations();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to delete: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ConversationTile extends StatelessWidget {
|
class _ConversationTile extends StatefulWidget {
|
||||||
final SecureConversation conversation;
|
final SecureConversation conversation;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
const _ConversationTile({
|
const _ConversationTile({
|
||||||
required this.conversation,
|
required this.conversation,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ConversationTile> createState() => _ConversationTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConversationTileState extends State<_ConversationTile> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return MouseRegion(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
decoration: BoxDecoration(
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
color: AppTheme.cardSurface,
|
child: Dismissible(
|
||||||
borderRadius: BorderRadius.circular(12),
|
key: Key('conv_${widget.conversation.id}'),
|
||||||
border: Border.all(
|
direction: DismissDirection.endToStart,
|
||||||
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
confirmDismiss: (direction) async {
|
||||||
width: 1,
|
widget.onDelete();
|
||||||
|
return false; // Let the full screen state handle the actual removal
|
||||||
|
},
|
||||||
|
background: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.error,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 24),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
child: Container(
|
||||||
child: Material(
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
color: Colors.transparent,
|
decoration: BoxDecoration(
|
||||||
child: InkWell(
|
color: AppTheme.cardSurface,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
onTap: onTap,
|
border: Border.all(
|
||||||
child: Padding(
|
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||||
padding: const EdgeInsets.all(16),
|
width: 1,
|
||||||
child: Row(
|
),
|
||||||
children: [
|
),
|
||||||
// Avatar
|
child: Material(
|
||||||
Container(
|
color: Colors.transparent,
|
||||||
width: 56,
|
child: InkWell(
|
||||||
height: 56,
|
borderRadius: BorderRadius.circular(12),
|
||||||
decoration: BoxDecoration(
|
onTap: widget.onTap,
|
||||||
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
child: Padding(
|
||||||
borderRadius: BorderRadius.circular(28),
|
padding: const EdgeInsets.all(16),
|
||||||
),
|
child: Row(
|
||||||
child: conversation.otherUserAvatarUrl != null
|
children: [
|
||||||
? ClipRRect(
|
// Avatar
|
||||||
borderRadius: BorderRadius.circular(28),
|
Container(
|
||||||
child: SignedMediaImage(
|
width: 56,
|
||||||
url: conversation.otherUserAvatarUrl!,
|
height: 56,
|
||||||
width: 56,
|
decoration: BoxDecoration(
|
||||||
height: 56,
|
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||||
fit: BoxFit.cover,
|
borderRadius: BorderRadius.circular(28),
|
||||||
),
|
),
|
||||||
)
|
child: widget.conversation.otherUserAvatarUrl != null
|
||||||
: Center(
|
? ClipRRect(
|
||||||
child: Text(
|
borderRadius: BorderRadius.circular(28),
|
||||||
(conversation.otherUserDisplayName ??
|
child: SignedMediaImage(
|
||||||
'@${conversation.otherUserHandle ?? 'Unknown'}')
|
url: widget.conversation.otherUserAvatarUrl!,
|
||||||
.isNotEmpty
|
width: 56,
|
||||||
? (conversation.otherUserDisplayName ??
|
height: 56,
|
||||||
'@${conversation.otherUserHandle ?? 'Unknown'}')[0]
|
fit: BoxFit.cover,
|
||||||
.toUpperCase()
|
),
|
||||||
: '?',
|
)
|
||||||
style: GoogleFonts.inter(
|
: Center(
|
||||||
color: AppTheme.navyBlue,
|
child: Text(
|
||||||
fontWeight: FontWeight.bold,
|
(widget.conversation.otherUserDisplayName ??
|
||||||
fontSize: 20,
|
'@${widget.conversation.otherUserHandle ?? 'Unknown'}')
|
||||||
|
.isNotEmpty
|
||||||
|
? (widget.conversation.otherUserDisplayName ??
|
||||||
|
'@${widget.conversation.otherUserHandle ?? 'Unknown'}')[0]
|
||||||
|
.toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppTheme.navyBlue,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Content
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.conversation.otherUserDisplayName ??
|
||||||
|
'@${widget.conversation.otherUserHandle ?? 'Unknown'}',
|
||||||
|
style: GoogleFonts.literata(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.navyBlue,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.conversation.lastMessageAt != null)
|
||||||
|
Text(
|
||||||
|
timeago.format(widget.conversation.lastMessageAt!),
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppTheme.textDisabled,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lock,
|
||||||
|
size: 12,
|
||||||
|
color: AppTheme.brightNavy.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.conversation.lastMessageAt != null
|
||||||
|
? 'Recent message'
|
||||||
|
: 'Start a conversation',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: widget.conversation.lastMessageAt != null
|
||||||
|
? AppTheme.textSecondary
|
||||||
|
: AppTheme.textDisabled,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Hover delete button for web/desktop
|
||||||
|
if (_isHovered)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: widget.onDelete,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: AppTheme.error,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: 'Delete conversation',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.error.withValues(alpha: 0.1),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
// Content
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
conversation.otherUserDisplayName ??
|
|
||||||
'@${conversation.otherUserHandle ?? 'Unknown'}',
|
|
||||||
style: GoogleFonts.literata(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.navyBlue,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (conversation.lastMessageAt != null)
|
|
||||||
Text(
|
|
||||||
timeago.format(conversation.lastMessageAt!),
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
color: AppTheme.textDisabled,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
],
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.lock,
|
|
||||||
size: 12,
|
|
||||||
color: AppTheme.brightNavy.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
conversation.lastMessageAt != null
|
|
||||||
? 'Recent message'
|
|
||||||
: 'Start a conversation',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
color: conversation.lastMessageAt != null
|
|
||||||
? AppTheme.textSecondary
|
|
||||||
: AppTheme.textDisabled,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
maxLines: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -360,35 +360,61 @@ class _SecureChatScreenState extends State<SecureChatScreen>
|
||||||
label: dateLabel,
|
label: dateLabel,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ChatBubbleWidget(
|
Dismissible(
|
||||||
key: ValueKey('${current.id}-${current.isPending}'),
|
key: ValueKey('swipe-${current.id}-${current.isPending}'),
|
||||||
message: current.text,
|
direction: DismissDirection.endToStart,
|
||||||
isMe: current.isMe,
|
confirmDismiss: (direction) async {
|
||||||
timestamp: current.timestamp,
|
if (current.isPending) {
|
||||||
isSending: current.isPending && !current.sendFailed,
|
_removePending(current.id);
|
||||||
sendFailed: current.sendFailed,
|
} else {
|
||||||
isDelivered: current.isDelivered,
|
_confirmDeleteMessage(current.id, forEveryone: false);
|
||||||
isRead: current.isRead,
|
}
|
||||||
decryptionFailed: current.decryptionFailed,
|
return false; // Let confirmation dialog handle it
|
||||||
isFirstInCluster: startsCluster,
|
},
|
||||||
isLastInCluster: endsCluster,
|
background: Container(
|
||||||
showAvatar: true,
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
avatarUrl: current.isMe
|
decoration: BoxDecoration(
|
||||||
? _currentUserAvatarUrl
|
color: AppTheme.error.withValues(alpha: 0.1),
|
||||||
: _otherUserAvatarUrl,
|
borderRadius: BorderRadius.circular(12),
|
||||||
avatarInitial: current.isMe
|
),
|
||||||
? _currentUserInitial
|
alignment: Alignment.centerRight,
|
||||||
: _otherUserInitial,
|
padding: const EdgeInsets.only(right: 24),
|
||||||
onLongPress: current.isPending
|
child: Icon(
|
||||||
? null
|
Icons.delete_outline,
|
||||||
: () => _showMessageOptions(current),
|
color: AppTheme.error,
|
||||||
onDelete: current.isPending
|
size: 24,
|
||||||
? () => _removePending(current.id)
|
),
|
||||||
: () => _confirmDeleteMessage(
|
),
|
||||||
current.id,
|
child: ChatBubbleWidget(
|
||||||
forEveryone: false,
|
key: ValueKey('${current.id}-${current.isPending}'),
|
||||||
),
|
message: current.text,
|
||||||
onReply: () => _startReply(current),
|
isMe: current.isMe,
|
||||||
|
timestamp: current.timestamp,
|
||||||
|
isSending: current.isPending && !current.sendFailed,
|
||||||
|
sendFailed: current.sendFailed,
|
||||||
|
isDelivered: current.isDelivered,
|
||||||
|
isRead: current.isRead,
|
||||||
|
decryptionFailed: current.decryptionFailed,
|
||||||
|
isFirstInCluster: startsCluster,
|
||||||
|
isLastInCluster: endsCluster,
|
||||||
|
showAvatar: true,
|
||||||
|
avatarUrl: current.isMe
|
||||||
|
? _currentUserAvatarUrl
|
||||||
|
: _otherUserAvatarUrl,
|
||||||
|
avatarInitial: current.isMe
|
||||||
|
? _currentUserInitial
|
||||||
|
: _otherUserInitial,
|
||||||
|
onLongPress: current.isPending
|
||||||
|
? null
|
||||||
|
: () => _showMessageOptions(current),
|
||||||
|
onDelete: current.isPending
|
||||||
|
? () => _removePending(current.id)
|
||||||
|
: () => _confirmDeleteMessage(
|
||||||
|
current.id,
|
||||||
|
forEveryone: false,
|
||||||
|
),
|
||||||
|
onReply: () => _startReply(current),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,12 @@ import '../models/tone_analysis.dart';
|
||||||
import '../utils/security_utils.dart';
|
import '../utils/security_utils.dart';
|
||||||
import '../utils/request_signing.dart';
|
import '../utils/request_signing.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
/// ApiService - Single source of truth for all backend communication.
|
/// ApiService - Single source of truth for all backend communication.
|
||||||
class ApiService {
|
class ApiService {
|
||||||
final AuthService _authService;
|
final AuthService _authService;
|
||||||
final http.Client _httpClient = http.Client();
|
final http.Client _httpClient;
|
||||||
|
|
||||||
ApiService(this._authService);
|
ApiService(this._authService) : _httpClient = http.Client();
|
||||||
|
|
||||||
// Singleton pattern helper if needed, but usually passed via DI/Riverpod
|
// Singleton pattern helper if needed, but usually passed via DI/Riverpod
|
||||||
static ApiService? _instance;
|
static ApiService? _instance;
|
||||||
|
|
@ -146,9 +145,6 @@ class ApiService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
'X-Rate-Limit-Remaining': '100',
|
|
||||||
'X-Rate-Limit-Reset': '3600',
|
|
||||||
'X-Request-ID': _generateRequestId(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -428,6 +424,7 @@ class ApiService {
|
||||||
required String authorId,
|
required String authorId,
|
||||||
int limit = 20,
|
int limit = 20,
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
|
bool onlyChains = false,
|
||||||
}) async {
|
}) async {
|
||||||
final data = await _callGoApi(
|
final data = await _callGoApi(
|
||||||
'/users/$authorId/posts',
|
'/users/$authorId/posts',
|
||||||
|
|
@ -435,6 +432,7 @@ class ApiService {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
'limit': limit.toString(),
|
'limit': limit.toString(),
|
||||||
'offset': offset.toString(),
|
'offset': offset.toString(),
|
||||||
|
if (onlyChains) 'chained': 'true',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -477,7 +475,7 @@ class ApiService {
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final data = await _callGoApi(
|
final data = await _callGoApi(
|
||||||
'/users/me/saved',
|
'/users/$userId/saved',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
queryParams: {'limit': '$limit', 'offset': '$offset'},
|
queryParams: {'limit': '$limit', 'offset': '$offset'},
|
||||||
);
|
);
|
||||||
|
|
@ -490,7 +488,12 @@ class ApiService {
|
||||||
int limit = 20,
|
int limit = 20,
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
}) async {
|
}) async {
|
||||||
return []; // Go API doesn't have a direct equivalent for 'get chained posts' yet, or use /feed?author_id=...&chained=true
|
return getProfilePosts(
|
||||||
|
authorId: authorId,
|
||||||
|
limit: limit,
|
||||||
|
offset: offset,
|
||||||
|
onlyChains: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Post>> getChainPosts({
|
Future<List<Post>> getChainPosts({
|
||||||
|
|
@ -1091,4 +1094,92 @@ class ApiService {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Key Backup & Recovery
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Upload an encrypted backup blob to cloud storage
|
||||||
|
/// [encryptedBlob] - The base64 encoded encrypted backup data
|
||||||
|
/// [salt] - The base64 encoded salt used for key derivation
|
||||||
|
/// [nonce] - The base64 encoded nonce used for encryption
|
||||||
|
/// [mac] - The base64 encoded auth tag
|
||||||
|
Future<Map<String, dynamic>> uploadBackup({
|
||||||
|
required String encryptedBlob,
|
||||||
|
required String salt,
|
||||||
|
required String nonce,
|
||||||
|
required String mac,
|
||||||
|
required String deviceName,
|
||||||
|
int version = 1,
|
||||||
|
}) async {
|
||||||
|
return await _callGoApi(
|
||||||
|
'/backup/upload',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
'encrypted_blob': encryptedBlob,
|
||||||
|
'salt': salt,
|
||||||
|
'nonce': nonce,
|
||||||
|
'mac': mac,
|
||||||
|
'device_name': deviceName,
|
||||||
|
'version': version,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the latest backup from cloud storage
|
||||||
|
Future<Map<String, dynamic>?> downloadBackup([String? backupId]) async {
|
||||||
|
try {
|
||||||
|
final path = backupId != null ? '/backup/download/$backupId' : '/backup/download';
|
||||||
|
final data = await _callGoApi(path, method: 'GET');
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.toString().contains('404')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all backups
|
||||||
|
Future<List<Map<String, dynamic>>> listBackups() async {
|
||||||
|
final data = await _callGoApi('/backup/list', method: 'GET');
|
||||||
|
return (data['backups'] as List).cast<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a backup
|
||||||
|
Future<void> deleteBackup(String backupId) async {
|
||||||
|
await _callGoApi('/backup/$backupId', method: 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get sync code for device pairing
|
||||||
|
Future<Map<String, dynamic>> generateSyncCode({
|
||||||
|
required String deviceName,
|
||||||
|
required String deviceFingerprint,
|
||||||
|
}) async {
|
||||||
|
return await _callGoApi(
|
||||||
|
'/backup/sync/generate-code',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
'device_name': deviceName,
|
||||||
|
'device_fingerprint': deviceFingerprint,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify sync code
|
||||||
|
Future<Map<String, dynamic>> verifySyncCode({
|
||||||
|
required String code,
|
||||||
|
required String deviceName,
|
||||||
|
required String deviceFingerprint,
|
||||||
|
}) async {
|
||||||
|
return await _callGoApi(
|
||||||
|
'/backup/sync/verify-code',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
'code': code,
|
||||||
|
'device_name': deviceName,
|
||||||
|
'device_fingerprint': deviceFingerprint,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,11 @@ import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:universal_html/html.dart' as html;
|
import 'package:universal_html/html.dart' as html;
|
||||||
import 'package:universal_html/js.dart' as js;
|
import 'package:universal_html/js.dart' as js;
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import '../../services/simple_e2ee_service.dart';
|
import '../../services/simple_e2ee_service.dart';
|
||||||
|
import '../../services/local_message_store.dart';
|
||||||
|
import '../../services/api_service.dart';
|
||||||
|
import '../../services/auth_service.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
|
||||||
/// Local key backup service for device-based key storage
|
/// Local key backup service for device-based key storage
|
||||||
|
|
@ -29,12 +33,45 @@ class LocalKeyBackupService {
|
||||||
static Future<Map<String, dynamic>> createEncryptedBackup({
|
static Future<Map<String, dynamic>> createEncryptedBackup({
|
||||||
required String password,
|
required String password,
|
||||||
required SimpleE2EEService e2eeService,
|
required SimpleE2EEService e2eeService,
|
||||||
|
bool includeKeys = true,
|
||||||
|
bool includeMessages = true,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
print('[BACKUP] Creating encrypted backup...');
|
print('[BACKUP] Creating encrypted backup (keys: $includeKeys, msgs: $includeMessages)...');
|
||||||
|
|
||||||
// 1. Export all keys from E2EE service
|
// 1. Export keys (if requested)
|
||||||
final keyData = await _exportAllKeys(e2eeService);
|
Map<String, dynamic>? keyData;
|
||||||
|
if (includeKeys) {
|
||||||
|
keyData = await _exportAllKeys(e2eeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Export messages if requested
|
||||||
|
List<Map<String, dynamic>>? messageData;
|
||||||
|
if (includeMessages) {
|
||||||
|
print('[BACKUP] Exporting messages...');
|
||||||
|
final messages = await LocalMessageStore.instance.getAllMessageRecords();
|
||||||
|
messageData = messages.map((m) => {
|
||||||
|
'conversationId': m.conversationId,
|
||||||
|
'messageId': m.messageId,
|
||||||
|
'plaintext': m.plaintext,
|
||||||
|
'senderId': m.senderId,
|
||||||
|
'createdAt': m.createdAt.toIso8601String(),
|
||||||
|
'messageType': m.messageType,
|
||||||
|
'deliveredAt': m.deliveredAt?.toIso8601String(),
|
||||||
|
'readAt': m.readAt?.toIso8601String(),
|
||||||
|
'expiresAt': m.expiresAt?.toIso8601String(),
|
||||||
|
}).toList();
|
||||||
|
print('[BACKUP] Exported ${messages.length} messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
final payloadData = {
|
||||||
|
if (keyData != null) 'keys': keyData,
|
||||||
|
if (messageData != null) 'messages': messageData,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payloadData.isEmpty) {
|
||||||
|
throw ArgumentError('Backup must include either keys or messages');
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Generate salt for key derivation
|
// 2. Generate salt for key derivation
|
||||||
final salt = _generateSalt();
|
final salt = _generateSalt();
|
||||||
|
|
@ -47,7 +84,7 @@ class LocalKeyBackupService {
|
||||||
final secretKey = SecretKey(encryptionKey);
|
final secretKey = SecretKey(encryptionKey);
|
||||||
final nonce = _generateNonce();
|
final nonce = _generateNonce();
|
||||||
|
|
||||||
final plaintext = utf8.encode(jsonEncode(keyData));
|
final plaintext = utf8.encode(jsonEncode(payloadData));
|
||||||
final secretBox = await algorithm.encrypt(
|
final secretBox = await algorithm.encrypt(
|
||||||
plaintext,
|
plaintext,
|
||||||
secretKey: secretKey,
|
secretKey: secretKey,
|
||||||
|
|
@ -65,7 +102,8 @@ class LocalKeyBackupService {
|
||||||
'metadata': {
|
'metadata': {
|
||||||
'app_name': 'Sojorn',
|
'app_name': 'Sojorn',
|
||||||
'platform': kIsWeb ? 'web' : defaultTargetPlatform.toString(),
|
'platform': kIsWeb ? 'web' : defaultTargetPlatform.toString(),
|
||||||
'key_count': keyData['keys']?.length ?? 0,
|
'key_count': keyData?['keys']?.length ?? 0,
|
||||||
|
'message_count': messageData?.length ?? 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -105,15 +143,43 @@ class LocalKeyBackupService {
|
||||||
final secretBox = SecretBox(ciphertext, nonce: nonce, mac: mac);
|
final secretBox = SecretBox(ciphertext, nonce: nonce, mac: mac);
|
||||||
|
|
||||||
final plaintext = await algorithm.decrypt(secretBox, secretKey: secretKey);
|
final plaintext = await algorithm.decrypt(secretBox, secretKey: secretKey);
|
||||||
final keyData = jsonDecode(utf8.decode(plaintext));
|
final payloadData = jsonDecode(utf8.decode(plaintext));
|
||||||
|
|
||||||
// 5. Import keys to E2EE service
|
// Handle legacy format (where root is keyData) or new format (where root has 'keys')
|
||||||
await _importAllKeys(keyData, e2eeService);
|
final keyData = payloadData.containsKey('keys') ? payloadData['keys'] : payloadData;
|
||||||
|
|
||||||
|
// 5. Import keys to E2EE service (if present)
|
||||||
|
if (keyData != null) {
|
||||||
|
await _importAllKeys(keyData, e2eeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Import messages if present
|
||||||
|
int restoredMessages = 0;
|
||||||
|
if (payloadData is Map && payloadData.containsKey('messages')) {
|
||||||
|
final messages = (payloadData['messages'] as List).cast<Map<String, dynamic>>();
|
||||||
|
print('[BACKUP] Restoring ${messages.length} messages...');
|
||||||
|
|
||||||
|
for (final m in messages) {
|
||||||
|
await LocalMessageStore.instance.saveMessageRecord(LocalMessageRecord(
|
||||||
|
conversationId: m['conversationId'],
|
||||||
|
messageId: m['messageId'],
|
||||||
|
plaintext: m['plaintext'],
|
||||||
|
senderId: m['senderId'],
|
||||||
|
createdAt: DateTime.parse(m['createdAt']),
|
||||||
|
messageType: m['messageType'],
|
||||||
|
deliveredAt: m['deliveredAt'] != null ? DateTime.parse(m['deliveredAt']) : null,
|
||||||
|
readAt: m['readAt'] != null ? DateTime.parse(m['readAt']) : null,
|
||||||
|
expiresAt: m['expiresAt'] != null ? DateTime.parse(m['expiresAt']) : null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
restoredMessages = messages.length;
|
||||||
|
}
|
||||||
|
|
||||||
print('[BACKUP] Backup restored successfully');
|
print('[BACKUP] Backup restored successfully');
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
'restored_keys': keyData['keys']?.length ?? 0,
|
'restored_keys': keyData != null ? (keyData['keys']?.length ?? 0) : 0,
|
||||||
|
'restored_messages': restoredMessages,
|
||||||
'backup_date': backup['created_at'],
|
'backup_date': backup['created_at'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -331,9 +397,79 @@ class LocalKeyBackupService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backup['version'] != _backupVersion) {
|
if (backup['version'] != _backupVersion) {
|
||||||
|
// Allow 1 if our current is 1.0 (lazy float check)
|
||||||
|
if (backup['version'].toString().startsWith('1')) return;
|
||||||
throw ArgumentError('Unsupported backup version: ${backup['version']}');
|
throw ArgumentError('Unsupported backup version: ${backup['version']}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload encrypted backup to cloud
|
||||||
|
static Future<void> uploadToCloud({
|
||||||
|
required Map<String, dynamic> backup,
|
||||||
|
}) async {
|
||||||
|
print('[BACKUP] Uploading to cloud...');
|
||||||
|
|
||||||
|
// Get device name
|
||||||
|
String deviceName = 'Unknown Device';
|
||||||
|
if (!kIsWeb) {
|
||||||
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final androidInfo = await deviceInfo.androidInfo;
|
||||||
|
deviceName = '${androidInfo.brand} ${androidInfo.model}';
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
final iosInfo = await deviceInfo.iosInfo;
|
||||||
|
deviceName = iosInfo.name;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deviceName = 'Web Browser';
|
||||||
|
}
|
||||||
|
|
||||||
|
await ApiService.instance.uploadBackup(
|
||||||
|
encryptedBlob: backup['ciphertext'],
|
||||||
|
salt: backup['salt'],
|
||||||
|
nonce: backup['nonce'],
|
||||||
|
mac: backup['mac'],
|
||||||
|
deviceName: deviceName,
|
||||||
|
version: 1, // Currently hardcoded version
|
||||||
|
);
|
||||||
|
print('[BACKUP] Upload successful');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore from cloud backup
|
||||||
|
static Future<Map<String, dynamic>> restoreFromCloud({
|
||||||
|
required String password,
|
||||||
|
required SimpleE2EEService e2eeService,
|
||||||
|
String? backupId,
|
||||||
|
}) async {
|
||||||
|
print('[BACKUP] Downloading from cloud...');
|
||||||
|
final backupData = await ApiService.instance.downloadBackup(backupId);
|
||||||
|
|
||||||
|
if (backupData == null) {
|
||||||
|
throw Exception('No backup found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the backup map format expected by restoreFromBackup
|
||||||
|
final backup = {
|
||||||
|
'version': backupData['version'].toString(), // Go sends int, we might need string
|
||||||
|
'created_at': backupData['created_at'],
|
||||||
|
'salt': backupData['salt'],
|
||||||
|
'nonce': backupData['nonce'],
|
||||||
|
'ciphertext': backupData['encrypted_blob'], // Go sends encrypted_blob
|
||||||
|
'mac': backupData['mac'],
|
||||||
|
'metadata': {
|
||||||
|
'device_name': backupData['device_name'],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fix version type mismatch if needed (our constant is '1.0', Go might send 1)
|
||||||
|
if (backup['version'] == '1') backup['version'] = '1.0';
|
||||||
|
|
||||||
|
return await restoreFromBackup(
|
||||||
|
backup: backup,
|
||||||
|
password: password,
|
||||||
|
e2eeService: e2eeService,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Screen for managing local key backups
|
/// Screen for managing local key backups
|
||||||
|
|
@ -348,6 +484,9 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
final SimpleE2EEService _e2eeService = SimpleE2EEService();
|
final SimpleE2EEService _e2eeService = SimpleE2EEService();
|
||||||
bool _isCreatingBackup = false;
|
bool _isCreatingBackup = false;
|
||||||
bool _isRestoringBackup = false;
|
bool _isRestoringBackup = false;
|
||||||
|
bool _includeMessages = true;
|
||||||
|
bool _includeKeys = true;
|
||||||
|
bool _useCloud = false; // Toggle for Cloud vs Local
|
||||||
String? _lastBackupPath;
|
String? _lastBackupPath;
|
||||||
DateTime? _lastBackupDate;
|
DateTime? _lastBackupDate;
|
||||||
|
|
||||||
|
|
@ -360,7 +499,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
title: Text(
|
title: Text(
|
||||||
'Key Backup',
|
'Full Backup & Recovery',
|
||||||
style: GoogleFonts.literata(
|
style: GoogleFonts.literata(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppTheme.navyBlue,
|
color: AppTheme.navyBlue,
|
||||||
|
|
@ -375,6 +514,8 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
children: [
|
children: [
|
||||||
_buildInfoCard(),
|
_buildInfoCard(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
_buildModeToggle(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
_buildCreateBackupSection(),
|
_buildCreateBackupSection(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildRestoreBackupSection(),
|
_buildRestoreBackupSection(),
|
||||||
|
|
@ -386,6 +527,53 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildModeToggle() {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.cardSurface,
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
border: Border.all(color: AppTheme.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildModeButton(title: 'Local File', isCloud: false),
|
||||||
|
_buildModeButton(title: 'Cloud Backup', isCloud: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeButton({required String title, required bool isCloud}) {
|
||||||
|
final isSelected = _useCloud == isCloud;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_useCloud = isCloud;
|
||||||
|
// Security Default: Don't send keys to cloud, do save keys locally
|
||||||
|
_includeKeys = !isCloud;
|
||||||
|
// UX Default: Always include messages for cloud (that's the point)
|
||||||
|
if (isCloud) _includeMessages = true;
|
||||||
|
}),
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? AppTheme.brightNavy : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isSelected ? Colors.white : AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard() {
|
Widget _buildInfoCard() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -405,7 +593,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
Icon(Icons.info_outline, color: AppTheme.brightNavy),
|
Icon(Icons.info_outline, color: AppTheme.brightNavy),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Local Key Backup',
|
_useCloud ? 'Encrypted Cloud Backup' : 'Local Key Backup',
|
||||||
style: GoogleFonts.literata(
|
style: GoogleFonts.literata(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppTheme.brightNavy,
|
color: AppTheme.brightNavy,
|
||||||
|
|
@ -416,8 +604,11 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Your encryption keys are saved locally on this device with password protection. '
|
_useCloud
|
||||||
'You can export them to a file for safekeeping or restore from a backup file.',
|
? 'Your messages are encrypted with your password and stored safely on our secure servers. '
|
||||||
|
'We never store your encryption keys on the server. You MUST have a local backup of your keys to restore these messages.'
|
||||||
|
: 'Your encryption keys and message history are saved locally on this device. '
|
||||||
|
'Keep this file safe! It is the only way to restore your identity.',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|
@ -442,7 +633,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
Icon(Icons.backup, color: AppTheme.brightNavy),
|
Icon(Icons.backup, color: AppTheme.brightNavy),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Create Backup',
|
_useCloud ? 'Upload to Cloud' : 'Create Backup',
|
||||||
style: GoogleFonts.literata(
|
style: GoogleFonts.literata(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppTheme.navyBlue,
|
color: AppTheme.navyBlue,
|
||||||
|
|
@ -453,17 +644,72 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Export your encryption keys to a password-protected backup file.',
|
_useCloud
|
||||||
|
? 'Encrypt and upload your message history to the cloud.'
|
||||||
|
: 'Export your keys and messages to a password-protected backup file.',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(
|
||||||
|
'Include Message History',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppTheme.navyBlue,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Backup all your secure conversations',
|
||||||
|
style: GoogleFonts.inter(fontSize: 12, color: AppTheme.textDisabled),
|
||||||
|
),
|
||||||
|
value: _includeMessages,
|
||||||
|
onChanged: (v) => setState(() => _includeMessages = v),
|
||||||
|
activeColor: AppTheme.brightNavy,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(
|
||||||
|
'Include Encryption Keys',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: _useCloud ? AppTheme.error : AppTheme.navyBlue, // Warn if cloud
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
_useCloud
|
||||||
|
? 'NOT RECOMMENDED for cloud backups. Keep keys local.'
|
||||||
|
: 'Required to restore your identity on a new device',
|
||||||
|
style: GoogleFonts.inter(fontSize: 12, color: AppTheme.textDisabled),
|
||||||
|
),
|
||||||
|
value: _includeKeys,
|
||||||
|
onChanged: (v) => setState(() => _includeKeys = v),
|
||||||
|
activeColor: _useCloud ? Colors.red : AppTheme.brightNavy,
|
||||||
|
),
|
||||||
|
if (_useCloud && !_includeKeys)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.security, size: 16, color: Colors.green),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Secure Mode: Zero Knowledge. Server cannot decrypt.',
|
||||||
|
style: GoogleFonts.inter(fontSize: 12, color: Colors.green),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isCreatingBackup ? null : _createBackup,
|
onPressed: _isCreatingBackup ? null : (_useCloud ? _createCloudBackup : _createBackup),
|
||||||
icon: _isCreatingBackup
|
icon: _isCreatingBackup
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
|
|
@ -473,8 +719,8 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Icon(Icons.file_download),
|
: Icon(_useCloud ? Icons.cloud_upload : Icons.file_download),
|
||||||
label: Text(_isCreatingBackup ? 'Creating...' : 'Export Backup'),
|
label: Text(_isCreatingBackup ? 'Processing...' : (_useCloud ? 'Upload Backup' : 'Export Backup')),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.brightNavy,
|
backgroundColor: AppTheme.brightNavy,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -501,7 +747,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
Icon(Icons.restore, color: AppTheme.brightNavy),
|
Icon(Icons.restore, color: AppTheme.brightNavy),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Restore Backup',
|
_useCloud ? 'Download & Restore' : 'Restore Backup',
|
||||||
style: GoogleFonts.literata(
|
style: GoogleFonts.literata(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppTheme.navyBlue,
|
color: AppTheme.navyBlue,
|
||||||
|
|
@ -512,7 +758,9 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Import your encryption keys from a backup file.',
|
_useCloud
|
||||||
|
? 'Download and decrypt the latest backup from the cloud.'
|
||||||
|
: 'Import your encryption keys from a backup file.',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|
@ -522,7 +770,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isRestoringBackup ? null : _restoreBackup,
|
onPressed: _isRestoringBackup ? null : (_useCloud ? _restoreCloudBackup : _restoreBackup),
|
||||||
icon: _isRestoringBackup
|
icon: _isRestoringBackup
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
|
|
@ -532,8 +780,8 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Icon(Icons.file_upload),
|
: Icon(_useCloud ? Icons.cloud_download : Icons.file_upload),
|
||||||
label: Text(_isRestoringBackup ? 'Restoring...' : 'Import Backup'),
|
label: Text(_isRestoringBackup ? 'Restoring...' : (_useCloud ? 'Download & Restore' : 'Import Backup')),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.royalPurple,
|
backgroundColor: AppTheme.royalPurple,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -590,6 +838,78 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _createCloudBackup() async {
|
||||||
|
try {
|
||||||
|
setState(() => _isCreatingBackup = true);
|
||||||
|
|
||||||
|
final password = await _showPasswordDialog('Encrypt Cloud Backup');
|
||||||
|
if (password == null) return;
|
||||||
|
|
||||||
|
final backup = await LocalKeyBackupService.createEncryptedBackup(
|
||||||
|
password: password,
|
||||||
|
e2eeService: _e2eeService,
|
||||||
|
includeMessages: _includeMessages,
|
||||||
|
includeKeys: _includeKeys, // Default false for cloud
|
||||||
|
);
|
||||||
|
|
||||||
|
await LocalKeyBackupService.uploadToCloud(backup: backup);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Backup uploaded securely!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Upload failed: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setState(() => _isCreatingBackup = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restoreCloudBackup() async {
|
||||||
|
try {
|
||||||
|
setState(() => _isRestoringBackup = true);
|
||||||
|
|
||||||
|
final password = await _showPasswordDialog('Decrypt Cloud Backup');
|
||||||
|
if (password == null) return;
|
||||||
|
|
||||||
|
final result = await LocalKeyBackupService.restoreFromCloud(
|
||||||
|
password: password,
|
||||||
|
e2eeService: _e2eeService,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Backup restored! ${result['restored_keys']} keys, ${result['restored_messages']} messages.'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Restore failed: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setState(() => _isRestoringBackup = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _createBackup() async {
|
Future<void> _createBackup() async {
|
||||||
try {
|
try {
|
||||||
setState(() => _isCreatingBackup = true);
|
setState(() => _isCreatingBackup = true);
|
||||||
|
|
@ -602,6 +922,8 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
final backup = await LocalKeyBackupService.createEncryptedBackup(
|
final backup = await LocalKeyBackupService.createEncryptedBackup(
|
||||||
password: password,
|
password: password,
|
||||||
e2eeService: _e2eeService,
|
e2eeService: _e2eeService,
|
||||||
|
includeMessages: _includeMessages,
|
||||||
|
includeKeys: _includeKeys, // Should be true for local
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save to device
|
// Save to device
|
||||||
|
|
@ -656,7 +978,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Backup restored successfully! ${result['restored_keys']} keys recovered.'),
|
content: Text('Backup restored successfully! ${result['restored_keys']} keys and ${result['restored_messages']} messages recovered.'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,44 @@ class LocalMessageStore {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get ALL message records across ALL conversations (for backup).
|
||||||
|
Future<List<LocalMessageRecord>> getAllMessageRecords() async {
|
||||||
|
final results = <LocalMessageRecord>[];
|
||||||
|
try {
|
||||||
|
await _ensureBoxes();
|
||||||
|
final allConversations = await getAllConversationIds();
|
||||||
|
|
||||||
|
for (final conversationId in allConversations) {
|
||||||
|
final messages = await getMessageRecordsForConversation(conversationId, limit: 10000); // High limit for backup
|
||||||
|
results.addAll(messages);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('[LOCAL_STORE] Failed to get all messages: $e');
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all conversation IDs.
|
||||||
|
Future<List<String>> getAllConversationIds() async {
|
||||||
|
await _ensureBoxes();
|
||||||
|
return _conversationBox!.keys.cast<String>().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a raw message record (for restore).
|
||||||
|
Future<bool> saveMessageRecord(LocalMessageRecord record) async {
|
||||||
|
return saveMessage(
|
||||||
|
conversationId: record.conversationId,
|
||||||
|
messageId: record.messageId,
|
||||||
|
plaintext: record.plaintext,
|
||||||
|
senderId: record.senderId,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
messageType: record.messageType,
|
||||||
|
deliveredAt: record.deliveredAt,
|
||||||
|
readAt: record.readAt,
|
||||||
|
expiresAt: record.expiresAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get list of message IDs for a conversation.
|
/// Get list of message IDs for a conversation.
|
||||||
Future<List<String>> getMessageIdsForConversation(String conversationId) async {
|
Future<List<String>> getMessageIdsForConversation(String conversationId) async {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -658,7 +658,7 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
// Automatic MAC error handling
|
// Automatic MAC error handling
|
||||||
int _macErrorCount = 0;
|
int _macErrorCount = 0;
|
||||||
static const int _maxMacErrors = 3;
|
static const int _maxMacErrors = 50;
|
||||||
DateTime? _lastMacErrorTime;
|
DateTime? _lastMacErrorTime;
|
||||||
|
|
||||||
void _handleMacError() {
|
void _handleMacError() {
|
||||||
|
|
@ -949,6 +949,7 @@ class SimpleE2EEService {
|
||||||
otkData.add({
|
otkData.add({
|
||||||
'key_id': i,
|
'key_id': i,
|
||||||
'public_key': base64Encode(otkPublic.bytes),
|
'public_key': base64Encode(otkPublic.bytes),
|
||||||
|
'private_key': base64Encode(await otk.extractPrivateKeyBytes()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -990,38 +991,70 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
final keys = backupData['keys'] as Map<String, dynamic>;
|
final keys = backupData['keys'] as Map<String, dynamic>;
|
||||||
|
|
||||||
// For now, we'll generate new keys since the cryptography library
|
// 1. Restore Identity Keys
|
||||||
// doesn't support direct private key import
|
if (keys.containsKey('identity_dh_private')) {
|
||||||
// In a production app, you'd need a more sophisticated key import system
|
print('[E2EE] Restoring Identity DH key...');
|
||||||
|
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['identity_dh_private']));
|
||||||
print('[E2EE] Note: Generating new keys due to import limitations');
|
|
||||||
|
|
||||||
// Generate new identity keys
|
|
||||||
_identityDhKeyPair = await _dhAlgo.newKeyPair();
|
|
||||||
_identitySigningKeyPair = await _signingAlgo.newKeyPair();
|
|
||||||
|
|
||||||
// Generate new signed prekey
|
|
||||||
_signedPreKey = await _dhAlgo.newKeyPair();
|
|
||||||
final spkPublic = await _signedPreKey!.extractPublicKey();
|
|
||||||
final spkSignature = await _signingAlgo.sign(spkPublic.bytes, keyPair: _identitySigningKeyPair!);
|
|
||||||
|
|
||||||
// Generate new OTKs
|
|
||||||
final importedOTKs = <SimpleKeyPair>[];
|
|
||||||
for (int i = 0; i < 20; i++) {
|
|
||||||
importedOTKs.add(await _dhAlgo.newKeyPair());
|
|
||||||
}
|
}
|
||||||
_oneTimePreKeys = importedOTKs;
|
|
||||||
|
|
||||||
// Save locally and republish to server
|
if (keys.containsKey('identity_signing_private')) {
|
||||||
|
print('[E2EE] Restoring Identity Signing key...');
|
||||||
|
_identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(keys['identity_signing_private']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Restore Signed PreKey
|
||||||
|
if (keys.containsKey('signed_prekey_private')) {
|
||||||
|
print('[E2EE] Restoring Signed PreKey...');
|
||||||
|
_signedPreKey = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['signed_prekey_private']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Restore One-Time PreKeys
|
||||||
|
if (keys.containsKey('one_time_prekeys') && keys['one_time_prekeys'] is List) {
|
||||||
|
final otkList = keys['one_time_prekeys'] as List;
|
||||||
|
final importedOTKs = <SimpleKeyPair>[];
|
||||||
|
for (final item in otkList) {
|
||||||
|
if (item is Map && item.containsKey('private_key')) {
|
||||||
|
importedOTKs.add(await _dhAlgo.newKeyPairFromSeed(base64Decode(item['private_key'])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_oneTimePreKeys = importedOTKs;
|
||||||
|
print('[E2EE] Restored ${_oneTimePreKeys!.length} OTKs');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Set User Context from metadata
|
||||||
|
if (backupData.containsKey('metadata')) {
|
||||||
|
final metadata = backupData['metadata'] as Map<String, dynamic>;
|
||||||
|
if (metadata.containsKey('user_id')) {
|
||||||
|
_initializedForUserId = metadata['user_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if metadata missing
|
||||||
|
if (_initializedForUserId == null) {
|
||||||
|
_initializedForUserId = _auth.currentUser?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Persist and Synchronize
|
||||||
if (_initializedForUserId != null) {
|
if (_initializedForUserId != null) {
|
||||||
|
print('[E2EE] Persisting restored keys to local storage...');
|
||||||
await _saveKeysToLocal(_initializedForUserId!);
|
await _saveKeysToLocal(_initializedForUserId!);
|
||||||
await _publishKeys(spkSignature.bytes);
|
|
||||||
|
// Republish to server to ensure backend is synchronized
|
||||||
|
// This is safe even if keys are identical
|
||||||
|
if (_identitySigningKeyPair != null && _signedPreKey != null) {
|
||||||
|
final spkPublic = await _signedPreKey!.extractPublicKey();
|
||||||
|
final signature = await _signingAlgo.sign(
|
||||||
|
spkPublic.bytes,
|
||||||
|
keyPair: _identitySigningKeyPair!
|
||||||
|
);
|
||||||
|
await _publishKeys(signature.bytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[E2EE] New keys generated and imported successfully');
|
print('[E2EE] Backup restoration complete. Old messages can now be decrypted.');
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[E2EE] Failed to import keys: $e');
|
print('[E2EE] CRITICAL: Failed to import keys: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,9 @@ class AppScaffold extends StatelessWidget {
|
||||||
|
|
||||||
PreferredSizeWidget _buildDefaultAppBar(BuildContext context) {
|
PreferredSizeWidget _buildDefaultAppBar(BuildContext context) {
|
||||||
return AppBar(
|
return AppBar(
|
||||||
title: title.isEmpty ? Image.asset('assets/images/toplogo.png') : Text(title),
|
title: title.isEmpty
|
||||||
|
? Image.asset('assets/images/toplogo.png', height: 40)
|
||||||
|
: Text(title),
|
||||||
centerTitle: centerTitle,
|
centerTitle: centerTitle,
|
||||||
leading: leading ?? _buildBackButton(context),
|
leading: leading ?? _buildBackButton(context),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../models/post.dart';
|
import '../models/post.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'media/signed_media_image.dart';
|
import 'media/signed_media_image.dart';
|
||||||
|
import 'reactions/reactions_display.dart';
|
||||||
|
|
||||||
class ChainQuoteWidget extends StatelessWidget {
|
class ChainQuoteWidget extends StatelessWidget {
|
||||||
final PostPreview parent;
|
final PostPreview parent;
|
||||||
|
|
@ -131,6 +132,15 @@ class ChainQuoteWidget extends StatelessWidget {
|
||||||
color: AppTheme.postContentLight,
|
color: AppTheme.postContentLight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (parent.reactions != null && parent.reactions!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ReactionsDisplay(
|
||||||
|
reactionCounts: parent.reactions!,
|
||||||
|
myReactions: parent.myReactions?.toSet() ?? {},
|
||||||
|
mode: ReactionsDisplayMode.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import '../models/thread_node.dart';
|
||||||
import '../providers/api_provider.dart';
|
import '../providers/api_provider.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/media/signed_media_image.dart';
|
import '../widgets/media/signed_media_image.dart';
|
||||||
|
import '../widgets/reactions/reactions_display.dart';
|
||||||
|
|
||||||
/// Kinetic Spatial Engine widget for layer-based thread navigation
|
/// Kinetic Spatial Engine widget for layer-based thread navigation
|
||||||
class KineticThreadWidget extends ConsumerStatefulWidget {
|
class KineticThreadWidget extends ConsumerStatefulWidget {
|
||||||
|
|
@ -1011,6 +1012,16 @@ class _KineticThreadWidgetState extends ConsumerState<KineticThreadWidget>
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (child.post.reactions != null && child.post.reactions!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: ReactionsDisplay(
|
||||||
|
reactionCounts: child.post.reactions!,
|
||||||
|
myReactions: child.post.myReactions?.toSet() ?? <String>{},
|
||||||
|
mode: ReactionsDisplayMode.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import '../../providers/api_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../sojorn_snackbar.dart';
|
import '../sojorn_snackbar.dart';
|
||||||
import '../reactions/reaction_picker.dart';
|
import '../reactions/reaction_picker.dart';
|
||||||
import '../reactions/smart_reaction_button.dart';
|
|
||||||
import '../reactions/reactions_display.dart';
|
import '../reactions/reactions_display.dart';
|
||||||
|
|
||||||
/// Post actions with a vibrant, clear, and energetic design.
|
/// Post actions with a vibrant, clear, and energetic design.
|
||||||
|
|
@ -51,7 +50,18 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
_seedReactionState();
|
_seedReactionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant PostActions oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.post != oldWidget.post) {
|
||||||
|
_isSaved = widget.post.isSaved ?? false;
|
||||||
|
_seedReactionState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _seedReactionState() {
|
void _seedReactionState() {
|
||||||
|
_reactionCounts.clear();
|
||||||
|
_myReactions.clear();
|
||||||
if (widget.post.reactions != null) {
|
if (widget.post.reactions != null) {
|
||||||
_reactionCounts.addAll(widget.post.reactions!);
|
_reactionCounts.addAll(widget.post.reactions!);
|
||||||
}
|
}
|
||||||
|
|
@ -207,13 +217,13 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Show all reactions in thread view
|
|
||||||
if (widget.showReactions && _reactionCounts.isNotEmpty)
|
if (widget.showReactions && _reactionCounts.isNotEmpty)
|
||||||
ReactionsDisplay(
|
ReactionsDisplay(
|
||||||
reactionCounts: _reactionCounts,
|
reactionCounts: _reactionCounts,
|
||||||
myReactions: _myReactions,
|
myReactions: _myReactions,
|
||||||
onReactionTap: _showReactionPicker,
|
onToggleReaction: _toggleReaction,
|
||||||
showAll: true,
|
onAddReaction: _showReactionPicker,
|
||||||
|
mode: ReactionsDisplayMode.full,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Actions row - reply moved to right
|
// Actions row - reply moved to right
|
||||||
|
|
@ -256,11 +266,13 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
// Right side: Reply and Reactions
|
// Right side: Reply and Reactions
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Smart reaction button (replaces appreciate button)
|
// Single Authority: ReactionsDisplay in compact mode for the actions row
|
||||||
SmartReactionButton(
|
ReactionsDisplay(
|
||||||
reactionCounts: _reactionCounts,
|
reactionCounts: _reactionCounts,
|
||||||
myReactions: _myReactions,
|
myReactions: _myReactions,
|
||||||
onPressed: _showReactionPicker,
|
onToggleReaction: _toggleReaction,
|
||||||
|
onAddReaction: _showReactionPicker,
|
||||||
|
mode: ReactionsDisplayMode.compact,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (allowChain)
|
if (allowChain)
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:timeago/timeago.dart' as timeago;
|
import 'package:timeago/timeago.dart' as timeago;
|
||||||
import '../../models/post.dart';
|
import '../../models/post.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../media/signed_media_image.dart';
|
import 'media/signed_media_image.dart';
|
||||||
import '../video_thumbnail_widget.dart';
|
import 'video_thumbnail_widget.dart';
|
||||||
import '../post/post_actions.dart';
|
import 'post/post_actions.dart';
|
||||||
|
|
||||||
/// Enhanced post widget with video thumbnail support (Twitter-style)
|
/// Enhanced post widget with video thumbnail support (Twitter-style)
|
||||||
class PostWithVideoWidget extends StatelessWidget {
|
class PostWithVideoWidget extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'dart:convert';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
|
||||||
class ReactionPicker extends StatefulWidget {
|
class ReactionPicker extends StatefulWidget {
|
||||||
|
|
@ -45,174 +48,106 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
_loadReactionSets();
|
_loadReactionSets();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Future<void> _loadReactionSets() async {
|
Future<void> _loadReactionSets() async {
|
||||||
// Start with emoji set
|
|
||||||
final reactionSets = <String, List<String>>{
|
|
||||||
'emoji': [
|
|
||||||
'❤️', '👍', '😂', '😮', '😢', '😡',
|
|
||||||
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
|
|
||||||
'😍', '🤣', '😊', '👌', '🙌', '💪',
|
|
||||||
'🎯', '⭐', '✨', '🌟', '💫', '☀️',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
final folderCredits = <String, String>{};
|
|
||||||
final tabOrder = ['emoji']; // Start with emoji, will add folders after
|
|
||||||
|
|
||||||
// Known reaction folders to check
|
|
||||||
final knownFolders = ['dotto', 'green', 'blue', 'purple'];
|
|
||||||
|
|
||||||
for (final folder in knownFolders) {
|
|
||||||
try {
|
try {
|
||||||
// Try to load credit file
|
final reactionSets = <String, List<String>>{
|
||||||
final creditContent = await _loadCreditFile(folder);
|
'emoji': [
|
||||||
folderCredits[folder] = creditContent;
|
'❤️', '👍', '😂', '😮', '😢', '😡',
|
||||||
|
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
|
||||||
|
'😍', '🤣', '😊', '👌', '🙌', '💪',
|
||||||
|
'🎯', '⭐', '✨', '🌟', '💫', '☀️',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// Try to load files from this folder
|
final folderCredits = <String, String>{};
|
||||||
final reactions = await _loadAllFilesFromFolder(folder);
|
final tabOrder = ['emoji'];
|
||||||
|
|
||||||
|
// Load the manifest to discover assets
|
||||||
|
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
||||||
|
final assetPaths = manifest.listAssets();
|
||||||
|
|
||||||
if (reactions.isNotEmpty) {
|
// Filter for reaction assets
|
||||||
reactionSets[folder] = reactions;
|
final reactionAssets = assetPaths.where((path) {
|
||||||
tabOrder.add(folder);
|
final lowerPath = path.toLowerCase();
|
||||||
|
return lowerPath.startsWith('assets/reactions/') &&
|
||||||
|
(lowerPath.endsWith('.png') ||
|
||||||
|
lowerPath.endsWith('.svg') ||
|
||||||
|
lowerPath.endsWith('.webp') ||
|
||||||
|
lowerPath.endsWith('.jpg') ||
|
||||||
|
lowerPath.endsWith('.jpeg') ||
|
||||||
|
lowerPath.endsWith('.gif'));
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
for (final path in reactionAssets) {
|
||||||
|
// Path format: assets/reactions/FOLDER_NAME/FILE_NAME.ext
|
||||||
|
final parts = path.split('/');
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
final folderName = parts[2];
|
||||||
|
|
||||||
|
if (!reactionSets.containsKey(folderName)) {
|
||||||
|
reactionSets[folderName] = [];
|
||||||
|
tabOrder.add(folderName);
|
||||||
|
|
||||||
|
// Try to load credit file if it's the first time we see this folder
|
||||||
|
try {
|
||||||
|
final creditPath = 'assets/reactions/$folderName/credit.md';
|
||||||
|
// Check if credit file exists in manifest too
|
||||||
|
if (assetPaths.contains(creditPath)) {
|
||||||
|
final creditData = await rootBundle.loadString(creditPath);
|
||||||
|
folderCredits[folderName] = creditData;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore missing credit files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reactionSets[folderName]!.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort reactions within each set by file name
|
||||||
|
for (final key in reactionSets.keys) {
|
||||||
|
if (key != 'emoji') {
|
||||||
|
reactionSets[key]!.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_reactionSets = reactionSets;
|
||||||
|
_folderCredits = folderCredits;
|
||||||
|
_tabOrder = tabOrder;
|
||||||
|
_isLoading = false;
|
||||||
|
|
||||||
|
_tabController = TabController(length: _tabOrder.length, vsync: this);
|
||||||
|
_tabController.addListener(() {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_currentTabIndex = _tabController.index;
|
||||||
|
_clearSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Error loading folder, skip it
|
print('[REACTIONS] Error scanning assets: $e');
|
||||||
}
|
// Fallback
|
||||||
}
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
setState(() {
|
_reactionSets = {
|
||||||
_reactionSets = reactionSets;
|
'emoji': ['❤️', '👍', '😂', '😮', '😢', '😡']
|
||||||
_folderCredits = folderCredits;
|
};
|
||||||
_tabOrder = tabOrder;
|
_tabOrder = ['emoji'];
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
_tabController = TabController(length: 1, vsync: this);
|
||||||
// Create TabController after setting up the data
|
});
|
||||||
_tabController = TabController(length: _tabOrder.length, vsync: this);
|
|
||||||
_tabController.addListener(() {
|
|
||||||
setState(() {
|
|
||||||
_currentTabIndex = _tabController.index;
|
|
||||||
_clearSearch();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> _loadAllFilesFromFolder(String folder) async {
|
|
||||||
final reactions = <String>[];
|
|
||||||
|
|
||||||
// Common file names to try (comprehensive list)
|
|
||||||
final possibleFiles = [
|
|
||||||
// Basic reactions
|
|
||||||
'heart', 'thumbs_up', 'laugh', 'lol', 'wow', 'sad', 'angry', 'mad',
|
|
||||||
'party', 'fire', 'clap', 'pray', 'hundred', 'thinking', 'ok',
|
|
||||||
|
|
||||||
// Face expressions
|
|
||||||
'smile', 'happy', 'grinning', 'beaming', 'wink', 'kiss', 'love',
|
|
||||||
'laughing', 'crying', 'tears', 'joy', 'giggle', 'chuckle',
|
|
||||||
'frown', 'worried', 'scared', 'fear', 'shock', 'surprised',
|
|
||||||
'confused', 'thinking_face', 'face_palm', 'eyeroll',
|
|
||||||
|
|
||||||
// Extended face names
|
|
||||||
'laughing_face', 'beaming_face', 'face_with_tears', 'grinning_face',
|
|
||||||
'smiling_face', 'winking_face', 'melting_face', 'upside_down_face',
|
|
||||||
'rolling_face', 'slightly_smiling_face', 'smiling_face_with_halo',
|
|
||||||
'smiling_face_with_hearts', 'face_with_monocle', 'nerd_face',
|
|
||||||
'party_face', 'sunglasses_face', 'disappointed_face', 'worried_face',
|
|
||||||
'anguished_face', 'fearful_face', 'downcast_face', 'loudly_crying_face',
|
|
||||||
|
|
||||||
// Special characters
|
|
||||||
'skull', 'ghost', 'robot', 'alien', 'monster', 'devil', 'angel',
|
|
||||||
'poop', 'vomit', 'sick', 'dizzy', 'sleeping', 'zzz',
|
|
||||||
|
|
||||||
// Hearts and love
|
|
||||||
'heart_with_arrow', 'broken_heart', 'sparkling_heart', 'two_hearts',
|
|
||||||
'revolving_hearts', 'heart_eyes', 'kissing_heart',
|
|
||||||
|
|
||||||
// Colored hearts
|
|
||||||
'green_heart', 'blue_heart', 'purple_heart', 'yellow_heart',
|
|
||||||
'black_heart', 'white_heart', 'brown_heart', 'orange_heart',
|
|
||||||
|
|
||||||
// Actions and objects
|
|
||||||
'thumbs_down', 'ok_hand', 'peace', 'victory', 'rock_on', 'call_me',
|
|
||||||
'point_up', 'point_down', 'point_left', 'point_right',
|
|
||||||
'raised_hand', 'wave', 'clap', 'high_five', 'pray', 'namaste',
|
|
||||||
|
|
||||||
// Nature and elements
|
|
||||||
'fire', 'water', 'earth', 'air', 'lightning', 'storm', 'rainbow',
|
|
||||||
'sun', 'moon', 'star', 'cloud', 'tree', 'flower', 'leaf',
|
|
||||||
|
|
||||||
// Food and drink
|
|
||||||
'pizza', 'burger', 'taco', 'ice_cream', 'coffee', 'tea', 'beer',
|
|
||||||
'wine', 'cocktail', 'cake', 'cookie', 'candy', 'chocolate',
|
|
||||||
|
|
||||||
// Animals
|
|
||||||
'dog', 'cat', 'mouse', 'rabbit', 'bear', 'lion', 'tiger', 'elephant',
|
|
||||||
'monkey', 'bird', 'fish', 'butterfly', 'spider', 'snake',
|
|
||||||
|
|
||||||
// Objects and symbols
|
|
||||||
'bomb', 'knife', 'gun', 'pistol', 'sword', 'shield', 'crown',
|
|
||||||
'gem', 'diamond', 'money', 'coin', 'dollar', 'gift', 'present',
|
|
||||||
|
|
||||||
// Numbers and symbols
|
|
||||||
'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten',
|
|
||||||
'zero', 'plus', 'minus', 'multiply', 'divide', 'equal', 'check', 'x',
|
|
||||||
|
|
||||||
// Common variations with underscores
|
|
||||||
'thumbs_up', 'thumbs_down', 'middle_finger', 'rock_on', 'peace_sign',
|
|
||||||
'ok_sign', 'victory_sign', 'call_me_hand', 'raised_hand', 'wave_hand',
|
|
||||||
|
|
||||||
// Emoji-style names
|
|
||||||
'face_with_open_mouth', 'face_with_closed_eyes', 'face_with_tears_of_joy',
|
|
||||||
'grinning_face_with_big_eyes', 'grinning_face_with_smiling_eyes',
|
|
||||||
'grinning_face_with_sweat', 'grinning_squinting_face', 'hugging_face',
|
|
||||||
'face_with_head_bandage', 'face_with_thermometer', 'face_with_bandage',
|
|
||||||
'nauseated_face', 'sneezing_face', 'yawning_face', 'face_with_cowboy_hat',
|
|
||||||
|
|
||||||
// More descriptive names
|
|
||||||
'party_popper', 'confetti_ball', 'balloon', 'ribbon', 'gift_ribbon',
|
|
||||||
'birthday_cake', 'wedding_cake', 'christmas_tree', 'pumpkin', 'ghost_halloween',
|
|
||||||
|
|
||||||
// Simple variations
|
|
||||||
'like', 'dislike', 'love', 'hate', 'yes', 'no', 'maybe', 'idk',
|
|
||||||
'cool', 'hot', 'cold', 'warm', 'fresh', 'old', 'new', 'classic',
|
|
||||||
|
|
||||||
// Tech and modern
|
|
||||||
'computer', 'phone', 'camera', 'video', 'music', 'game', 'controller',
|
|
||||||
'mouse', 'keyboard', 'screen', 'monitor', 'laptop', 'tablet',
|
|
||||||
|
|
||||||
// Expressions
|
|
||||||
'lol', 'lmao', 'rofl', 'omg', 'wtf', 'smh', 'idc', 'ngl', 'fr',
|
|
||||||
'tbh', 'iykyk', 'rn', 'asap', 'fyi', 'btw', 'imo', 'imho',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Try both PNG and SVG extensions for each possible file name
|
|
||||||
for (final fileName in possibleFiles) {
|
|
||||||
for (final extension in ['png', 'svg']) {
|
|
||||||
final fullPath = 'reactions/$folder/$fileName.$extension';
|
|
||||||
try {
|
|
||||||
// Try to load the file to check if it exists
|
|
||||||
await rootBundle.load(fullPath);
|
|
||||||
final assetPath = 'assets/$fullPath';
|
|
||||||
reactions.add(assetPath);
|
|
||||||
break; // Found this file, don't try other extensions
|
|
||||||
} catch (e) {
|
|
||||||
// File doesn't exist, try next extension
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return reactions;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _loadCreditFile(String folder) async {
|
|
||||||
try {
|
|
||||||
final creditData = await rootBundle.loadString('reactions/$folder/credit.md');
|
|
||||||
return creditData;
|
|
||||||
} catch (e) {
|
|
||||||
// Return default credit if file not found
|
|
||||||
return '# $folder Reaction Set\n\nCustom reaction set for Sojorn';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|
@ -447,9 +382,7 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
|
||||||
|
|
||||||
// Reaction grid
|
// Reaction grid
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: _isSearching && _filteredReactions.isNotEmpty
|
height: 420, // Increased height to show more rows at once
|
||||||
? (_filteredReactions.length / 6).ceil() * 60
|
|
||||||
: 240, // Dynamic height based on search results
|
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: _tabOrder.map((tabName) {
|
children: _tabOrder.map((tabName) {
|
||||||
|
|
@ -461,7 +394,7 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
|
||||||
children: [
|
children: [
|
||||||
// Reaction grid
|
// Reaction grid
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, isEmoji),
|
child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, !isEmoji),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Credit section (only for non-emoji tabs)
|
// Credit section (only for non-emoji tabs)
|
||||||
|
|
@ -500,71 +433,23 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCreditDisplay(String credit) {
|
Widget _buildCreditDisplay(String credit) {
|
||||||
final lines = credit.split('\n');
|
return MarkdownBody(
|
||||||
final widgets = <Widget>[];
|
data: credit,
|
||||||
|
selectable: true,
|
||||||
for (final line in lines) {
|
onTapLink: (text, href, title) {
|
||||||
if (line.trim().isEmpty) continue;
|
if (href != null) {
|
||||||
|
launchUrl(Uri.parse(href));
|
||||||
if (line.startsWith('# ')) {
|
}
|
||||||
// Title
|
},
|
||||||
widgets.add(Text(
|
styleSheet: MarkdownStyleSheet(
|
||||||
line.substring(2).trim(),
|
p: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
|
||||||
style: GoogleFonts.inter(
|
h1: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
|
||||||
fontSize: 11,
|
h2: GoogleFonts.inter(fontSize: 11, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
|
||||||
fontWeight: FontWeight.w600,
|
listBullet: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
|
||||||
color: AppTheme.textPrimary,
|
strong: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
|
||||||
),
|
em: GoogleFonts.inter(fontSize: 10, fontStyle: FontStyle.italic, color: AppTheme.textPrimary),
|
||||||
));
|
a: GoogleFonts.inter(fontSize: 10, color: AppTheme.brightNavy, decoration: TextDecoration.underline),
|
||||||
} else if (line.startsWith('**') && line.endsWith('**')) {
|
),
|
||||||
// Bold text
|
|
||||||
widgets.add(Text(
|
|
||||||
line.replaceAll('**', '').trim(),
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
} else if (line.startsWith('*') && line.endsWith('*')) {
|
|
||||||
// Italic text
|
|
||||||
widgets.add(Text(
|
|
||||||
line.replaceAll('*', '').trim(),
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 10,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
} else if (line.startsWith('- ')) {
|
|
||||||
// List item
|
|
||||||
widgets.add(Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8),
|
|
||||||
child: Text(
|
|
||||||
'• ${line.substring(2).trim()}',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 10,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
// Regular text
|
|
||||||
widgets.add(Text(
|
|
||||||
line.trim(),
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 10,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
widgets.add(const SizedBox(height: 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: widgets,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -575,8 +460,8 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
|
||||||
bool useImages,
|
bool useImages,
|
||||||
) {
|
) {
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
shrinkWrap: true,
|
padding: const EdgeInsets.all(4),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 6,
|
crossAxisCount: 6,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 8,
|
||||||
|
|
@ -594,7 +479,10 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
widget.onReactionSelected(reaction);
|
final result = reaction.startsWith('assets/')
|
||||||
|
? 'asset:$reaction'
|
||||||
|
: reaction;
|
||||||
|
widget.onReactionSelected(result);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -653,7 +541,11 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImageReaction(String imagePath) {
|
Widget _buildImageReaction(String reaction) {
|
||||||
|
final imagePath = reaction.startsWith('asset:')
|
||||||
|
? reaction.replaceFirst('asset:', '')
|
||||||
|
: reaction;
|
||||||
|
|
||||||
if (imagePath.endsWith('.svg')) {
|
if (imagePath.endsWith('.svg')) {
|
||||||
return SvgPicture.asset(
|
return SvgPicture.asset(
|
||||||
imagePath,
|
imagePath,
|
||||||
|
|
@ -669,7 +561,6 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
// Fallback to emoji if image not found
|
|
||||||
return Icon(
|
return Icon(
|
||||||
Icons.image_not_supported,
|
Icons.image_not_supported,
|
||||||
size: 24,
|
size: 24,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@ import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ReactionStrip extends StatelessWidget {
|
class ReactionStrip extends StatelessWidget {
|
||||||
final Map<String, int> reactions;
|
final Map<String, int> reactions;
|
||||||
final Set<String> myReactions;
|
final Set<String> myReactions;
|
||||||
|
|
@ -194,8 +197,19 @@ class _ReactionIcon extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (reactionId.startsWith('asset:')) {
|
if (reactionId.startsWith('asset:') || reactionId.startsWith('assets/')) {
|
||||||
final assetPath = reactionId.replaceFirst('asset:', '');
|
final assetPath = reactionId.startsWith('asset:')
|
||||||
|
? reactionId.replaceFirst('asset:', '')
|
||||||
|
: reactionId;
|
||||||
|
|
||||||
|
if (assetPath.endsWith('.svg')) {
|
||||||
|
return SvgPicture.asset(
|
||||||
|
assetPath,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
placeholderBuilder: (_) => const SizedBox(width: 18, height: 18),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Image.asset(
|
return Image.asset(
|
||||||
assetPath,
|
assetPath,
|
||||||
width: 18,
|
width: 18,
|
||||||
|
|
@ -240,10 +254,8 @@ class _ReactionPickerSheetState extends State<_ReactionPickerSheet> {
|
||||||
|
|
||||||
Future<void> _loadAssetReactions() async {
|
Future<void> _loadAssetReactions() async {
|
||||||
try {
|
try {
|
||||||
final manifest = await DefaultAssetBundle.of(context)
|
final manifest = await AssetManifest.loadFromAssetBundle(DefaultAssetBundle.of(context));
|
||||||
.loadString('AssetManifest.json');
|
final keys = manifest.listAssets()
|
||||||
final map = jsonDecode(manifest) as Map<String, dynamic>;
|
|
||||||
final keys = map.keys
|
|
||||||
.where((key) => key.startsWith('assets/reactions/'))
|
.where((key) => key.startsWith('assets/reactions/'))
|
||||||
.toList()
|
.toList()
|
||||||
..sort();
|
..sort();
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,256 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
|
||||||
/// Displays all reactions for a post
|
enum ReactionsDisplayMode {
|
||||||
/// Used in thread/detail views to show comprehensive reaction breakdown
|
/// Comprehensive list of all reactions (Thread view)
|
||||||
|
full,
|
||||||
|
/// Single prioritized reaction chip (Feed view)
|
||||||
|
compact,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single Authority for reaction presentation and interaction.
|
||||||
|
///
|
||||||
|
/// Handles:
|
||||||
|
/// - [ReactionsDisplayMode.full]: Multiple chips with optional 'Add' button.
|
||||||
|
/// - [ReactionsDisplayMode.compact]: Single prioritized chip.
|
||||||
class ReactionsDisplay extends StatelessWidget {
|
class ReactionsDisplay extends StatelessWidget {
|
||||||
final Map<String, int> reactionCounts;
|
final Map<String, int> reactionCounts;
|
||||||
final Set<String> myReactions;
|
final Set<String> myReactions;
|
||||||
final VoidCallback? onReactionTap;
|
final Map<String, List<String>>? reactionUsers;
|
||||||
final bool showAll;
|
final Function(String)? onToggleReaction;
|
||||||
|
final VoidCallback? onAddReaction;
|
||||||
|
final ReactionsDisplayMode mode;
|
||||||
|
final EdgeInsets? padding;
|
||||||
|
|
||||||
const ReactionsDisplay({
|
const ReactionsDisplay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.reactionCounts,
|
required this.reactionCounts,
|
||||||
required this.myReactions,
|
required this.myReactions,
|
||||||
this.onReactionTap,
|
this.reactionUsers,
|
||||||
this.showAll = true,
|
this.onToggleReaction,
|
||||||
|
this.onAddReaction,
|
||||||
|
this.mode = ReactionsDisplayMode.full,
|
||||||
|
this.padding,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (reactionCounts.isEmpty) {
|
if (reactionCounts.isEmpty && onAddReaction == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MapEntry<String, int>> reactions;
|
if (mode == ReactionsDisplayMode.compact) {
|
||||||
if (showAll) {
|
return _buildCompactView();
|
||||||
reactions = reactionCounts.entries.toList();
|
|
||||||
reactions.sort((a, b) => b.value.compareTo(a.value));
|
|
||||||
} else {
|
|
||||||
reactions = reactionCounts.entries.take(3).toList();
|
|
||||||
reactions.sort((a, b) => b.value.compareTo(a.value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return _buildFullView();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompactView() {
|
||||||
|
if (reactionCounts.isEmpty) {
|
||||||
|
return _ReactionAddButton(onTap: onAddReaction ?? () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority: User's reaction > Top reaction
|
||||||
|
String? displayEmoji;
|
||||||
|
if (myReactions.isNotEmpty) {
|
||||||
|
displayEmoji = myReactions.first;
|
||||||
|
} else {
|
||||||
|
displayEmoji = reactionCounts.entries
|
||||||
|
.reduce((a, b) => a.value > b.value ? a : b)
|
||||||
|
.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ReactionChip(
|
||||||
|
reactionId: displayEmoji,
|
||||||
|
count: reactionCounts[displayEmoji] ?? 0,
|
||||||
|
isSelected: myReactions.contains(displayEmoji),
|
||||||
|
tooltipNames: reactionUsers?[displayEmoji],
|
||||||
|
onTap: () => onToggleReaction?.call(displayEmoji!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFullView() {
|
||||||
|
final sortedEntries = reactionCounts.entries.toList()
|
||||||
|
..sort((a, b) => b.value.compareTo(a.value));
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: padding ?? const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: reactions.map((entry) {
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
final emoji = entry.key;
|
children: [
|
||||||
final count = entry.value;
|
...sortedEntries.map((entry) {
|
||||||
final isMyReaction = myReactions.contains(emoji);
|
return _ReactionChip(
|
||||||
|
reactionId: entry.key,
|
||||||
return GestureDetector(
|
count: entry.value,
|
||||||
onTap: onReactionTap,
|
isSelected: myReactions.contains(entry.key),
|
||||||
child: Container(
|
tooltipNames: reactionUsers?[entry.key],
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
onTap: () => onToggleReaction?.call(entry.key),
|
||||||
decoration: BoxDecoration(
|
);
|
||||||
color: isMyReaction
|
}),
|
||||||
? AppTheme.brightNavy.withValues(alpha: 0.15)
|
if (onAddReaction != null)
|
||||||
: AppTheme.navyBlue.withValues(alpha: 0.08),
|
_ReactionAddButton(onTap: onAddReaction!),
|
||||||
borderRadius: BorderRadius.circular(16),
|
],
|
||||||
border: isMyReaction
|
|
||||||
? Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.3))
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
emoji,
|
|
||||||
style: const TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
count > 99 ? '99+' : '$count',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
color: isMyReaction ? AppTheme.brightNavy : AppTheme.textSecondary,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ReactionChip extends StatefulWidget {
|
||||||
|
final String reactionId;
|
||||||
|
final int count;
|
||||||
|
final bool isSelected;
|
||||||
|
final List<String>? tooltipNames;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ReactionChip({
|
||||||
|
required this.reactionId,
|
||||||
|
required this.count,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
this.tooltipNames,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ReactionChip> createState() => _ReactionChipState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReactionChipState extends State<_ReactionChip> {
|
||||||
|
int _tapCount = 0;
|
||||||
|
|
||||||
|
void _handleTap() {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
setState(() => _tapCount += 1);
|
||||||
|
widget.onTap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isMyReaction = widget.isSelected;
|
||||||
|
|
||||||
|
final chip = GestureDetector(
|
||||||
|
onTap: _handleTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMyReaction
|
||||||
|
? AppTheme.brightNavy.withValues(alpha: 0.15)
|
||||||
|
: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isMyReaction
|
||||||
|
? Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.3))
|
||||||
|
: null,
|
||||||
|
boxShadow: isMyReaction
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_ReactionIcon(reactionId: widget.reactionId, size: 18),
|
||||||
|
if (widget.count > 0) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
widget.count > 99 ? '99+' : '${widget.count}',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: isMyReaction ? AppTheme.brightNavy : AppTheme.textSecondary,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.animate(key: ValueKey('tap_$_tapCount'))
|
||||||
|
.scale(begin: const Offset(1, 1), end: const Offset(1.1, 1.1), duration: 100.ms, curve: Curves.easeOut)
|
||||||
|
.then()
|
||||||
|
.scale(begin: const Offset(1.1, 1.1), end: const Offset(1, 1), duration: 150.ms, curve: Curves.easeOutBack);
|
||||||
|
|
||||||
|
final names = widget.tooltipNames;
|
||||||
|
if (names == null || names.isEmpty) return chip;
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: names.take(5).join(', '),
|
||||||
|
child: chip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReactionAddButton extends StatelessWidget {
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ReactionAddButton({required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.add_reaction_outlined,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReactionIcon extends StatelessWidget {
|
||||||
|
final String reactionId;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const _ReactionIcon({required this.reactionId, this.size = 14});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (reactionId.startsWith('assets/') || reactionId.startsWith('asset:')) {
|
||||||
|
final assetPath = reactionId.startsWith('asset:')
|
||||||
|
? reactionId.replaceFirst('asset:', '')
|
||||||
|
: reactionId;
|
||||||
|
|
||||||
|
if (assetPath.endsWith('.svg')) {
|
||||||
|
return SvgPicture.asset(
|
||||||
|
assetPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Image.asset(
|
||||||
|
assetPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
reactionId,
|
||||||
|
style: TextStyle(fontSize: size),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
|
||||||
class SmartReactionButton extends ConsumerWidget {
|
class SmartReactionButton extends ConsumerWidget {
|
||||||
|
|
@ -15,6 +16,32 @@ class SmartReactionButton extends ConsumerWidget {
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Widget _buildReactionContent(String reaction) {
|
||||||
|
if (reaction.startsWith('assets/') || reaction.startsWith('asset:')) {
|
||||||
|
final assetPath = reaction.startsWith('asset:')
|
||||||
|
? reaction.replaceFirst('asset:', '')
|
||||||
|
: reaction;
|
||||||
|
|
||||||
|
if (assetPath.endsWith('.svg')) {
|
||||||
|
return SvgPicture.asset(
|
||||||
|
assetPath,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Image.asset(
|
||||||
|
assetPath,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
reaction,
|
||||||
|
style: const TextStyle(fontSize: 18),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
// Determine what to show
|
// Determine what to show
|
||||||
|
|
@ -28,10 +55,7 @@ class SmartReactionButton extends ConsumerWidget {
|
||||||
icon: Row(
|
icon: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_buildReactionContent(myReaction),
|
||||||
myReaction,
|
|
||||||
style: const TextStyle(fontSize: 18),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
totalCount > 99 ? '99+' : '$totalCount',
|
totalCount > 99 ? '99+' : '$totalCount',
|
||||||
|
|
@ -61,10 +85,7 @@ class SmartReactionButton extends ConsumerWidget {
|
||||||
icon: Row(
|
icon: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_buildReactionContent(topReaction.key),
|
||||||
topReaction.key,
|
|
||||||
style: const TextStyle(fontSize: 18),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
totalCount > 99 ? '99+' : '$totalCount',
|
totalCount > 99 ? '99+' : '$totalCount',
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,14 @@ class sojornAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
onPressed: onBackPressed ?? () => Navigator.of(context).pop(),
|
onPressed: onBackPressed ?? () => Navigator.of(context).pop(),
|
||||||
)
|
)
|
||||||
: null),
|
: null),
|
||||||
title: title != null
|
title: (title == null || title!.isEmpty)
|
||||||
? Text(
|
? Image.asset(
|
||||||
title!,
|
'assets/images/toplogo.png',
|
||||||
|
height: 44,
|
||||||
)
|
)
|
||||||
: null,
|
: Text(
|
||||||
|
title!,
|
||||||
|
),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
bottom: bottom,
|
bottom: bottom,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.6.2"
|
version: "3.6.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -213,10 +221,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: cross_file
|
name: cross_file
|
||||||
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+1"
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -305,6 +313,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.1"
|
version: "0.4.1"
|
||||||
|
dio:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.1"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
emoji_picker_flutter:
|
emoji_picker_flutter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -325,10 +349,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.5"
|
||||||
ffmpeg_kit_flutter_new:
|
ffmpeg_kit_flutter_new:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -829,6 +853,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.5"
|
version: "0.2.5"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
go_router:
|
go_router:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -869,6 +901,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
html:
|
html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -913,10 +953,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image_picker_android
|
name: image_picker_android
|
||||||
sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677"
|
sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.8.13+10"
|
version: "0.8.13+13"
|
||||||
image_picker_for_web:
|
image_picker_for_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -929,10 +969,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image_picker_ios
|
name: image_picker_ios
|
||||||
sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad"
|
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.8.13+3"
|
version: "0.8.13+6"
|
||||||
image_picker_linux:
|
image_picker_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -985,10 +1025,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.0"
|
version: "4.10.0"
|
||||||
latlong2:
|
latlong2:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1025,10 +1065,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.1.0"
|
||||||
lists:
|
lists:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1141,6 +1181,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.4"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1149,6 +1197,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.2.5"
|
||||||
octo_image:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1209,10 +1265,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.1"
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1345,10 +1401,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pro_video_editor
|
name: pro_video_editor
|
||||||
sha256: "1b3229558edaf2b1c75d771a83c5ac8897d63d0dc9845f95de7b8428d5d6fbdf"
|
sha256: "18f62235212ff779a2ca967df4ce06cac22b7ff45051f46519754d94db2b04ff"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.4.1"
|
||||||
proj4dart:
|
proj4dart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1365,6 +1421,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
quill_native_bridge:
|
quill_native_bridge:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1473,10 +1537,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
|
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.18"
|
version: "2.4.20"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1734,10 +1798,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1830,10 +1894,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: video_player_avfoundation
|
name: video_player_avfoundation
|
||||||
sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
|
sha256: "7cc0a9257103851eb299a2407e895b0fd6832d323dcfde622a23cdc25a1de269"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.8.9"
|
version: "2.9.0"
|
||||||
video_player_platform_interface:
|
video_player_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1963,5 +2027,5 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.0 <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.38.4"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
name: sojorn
|
name: sojorn
|
||||||
description: "sojorn - Friend's Only"
|
description: "Sojorn - Friend's Only. A product of MPLS LLC."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ dependencies:
|
||||||
|
|
||||||
# HTTP & API
|
# HTTP & API
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
|
dio: ^5.7.0
|
||||||
|
|
||||||
# UI & Utilities
|
# UI & Utilities
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
|
@ -96,6 +97,13 @@ flutter:
|
||||||
- assets/images/applogo.png
|
- assets/images/applogo.png
|
||||||
- assets/images/toplogo.png
|
- assets/images/toplogo.png
|
||||||
- assets/reactions/
|
- assets/reactions/
|
||||||
|
- assets/reactions/dotto/
|
||||||
|
- assets/reactions/blue/
|
||||||
|
- assets/reactions/green/
|
||||||
|
- assets/reactions/purple/
|
||||||
|
- assets/icon/
|
||||||
|
- assets/rive/
|
||||||
|
- assets/audio/
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
intl: 0.19.0
|
intl: 0.19.0
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
<meta name="description" content="A new Flutter project.">
|
<meta name="description" content="Sojorn - Friend's Only. A product of MPLS LLC.">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#0175C2",
|
||||||
"theme_color": "#0175C2",
|
"theme_color": "#0175C2",
|
||||||
"description": "A new Flutter project.",
|
"description": "Sojorn - Friend's Only. A product of MPLS LLC.",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|
@ -32,4 +32,4 @@
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -142,6 +142,7 @@ encrypted_messages (ciphertext + header + metadata)
|
||||||
- ✅ OTK management (generation, usage, deletion)
|
- ✅ OTK management (generation, usage, deletion)
|
||||||
- ✅ Backend key storage/retrieval
|
- ✅ Backend key storage/retrieval
|
||||||
- ✅ Cross-platform encryption (Android↔Web)
|
- ✅ Cross-platform encryption (Android↔Web)
|
||||||
|
- ✅ **Full Backup & Recovery** (Keys + Messages)
|
||||||
|
|
||||||
### Key Files Modified
|
### Key Files Modified
|
||||||
```
|
```
|
||||||
|
|
@ -188,63 +189,56 @@ Receiver:
|
||||||
[DECRYPT] SUCCESS: Decrypted message: "[message_text]"
|
[DECRYPT] SUCCESS: Decrypted message: "[message_text]"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Steps: Message Recovery
|
## Backup & Recovery System ✅
|
||||||
|
|
||||||
### Problem
|
### Overview
|
||||||
When users uninstall the app or lose local keys, they cannot decrypt historical messages.
|
A robust local backup and recovery system has been implemented to address the risk of data loss on device changes or app uninstalls. This system allows users to export their cryptographic identity and message history into a secure, portable file.
|
||||||
|
|
||||||
### Solution Requirements
|
### Architecture
|
||||||
1. **Key Backup Strategy**: Securely backup encryption keys
|
|
||||||
2. **Message Recovery**: Allow decryption of historical messages after key recovery
|
|
||||||
3. **Security**: Maintain E2EE guarantees while enabling recovery
|
|
||||||
|
|
||||||
### Proposed Solutions
|
#### 1. Security Model
|
||||||
|
- **Encryption**: AES-256-GCM
|
||||||
|
- **Key Derivation**: Argon2id (from user password)
|
||||||
|
- **Storage**: Local file system (portable JSON file)
|
||||||
|
- **Trust**: Zero-knowledge (server never sees the backup file or password)
|
||||||
|
|
||||||
#### Option 1: Cloud Key Backup (Recommended)
|
#### 2. Backup Content
|
||||||
- Encrypt identity keys with user password
|
The encrypted backup file contains two main components:
|
||||||
- Store encrypted backup in cloud storage
|
1. **Key Material**:
|
||||||
- Recover keys with password authentication
|
* Identity Key Pair (Ed25519 & X25519)
|
||||||
|
* Signed PreKey Pair (with signature)
|
||||||
|
* One-Time PreKeys (all unused keys)
|
||||||
|
2. **Message History** (Optional):
|
||||||
|
* Full plaintext message history
|
||||||
|
* Metadata (sender, timestamp, etc.)
|
||||||
|
* *Note: Messages are decrypted from local storage and re-encrypted with the backup password for portability.*
|
||||||
|
|
||||||
**Pros**:
|
#### 3. Backup Flow
|
||||||
- Most user-friendly
|
1. **User Initiation**: User selects "Full Backup & Recovery" in settings.
|
||||||
- Maintains security (password-protected)
|
2. **Password Entry**: User sets a strong backup password.
|
||||||
- Technically straightforward
|
3. **Data Gathering**:
|
||||||
- Reversible if needed
|
* `SimpleE2EEService` exports all key pairs.
|
||||||
|
* (Optional) `LocalMessageStore` exports all message records.
|
||||||
|
4. **Encryption**:
|
||||||
|
* Salt & Nonce generated.
|
||||||
|
* Key derived from password via Argon2id.
|
||||||
|
* Payload (keys + messages) encrypted via AES-GCM.
|
||||||
|
5. **File Generation**: JSON file containing ciphertext, salt, nonce, and metadata is saved to device.
|
||||||
|
|
||||||
#### Option 2: Social Recovery
|
#### 4. Restore Flow
|
||||||
- Allow trusted contacts to help recover keys
|
1. **File Selection**: User selects the `.json` backup file.
|
||||||
- Use Shamir's Secret Sharing for security
|
2. **Decryption**:
|
||||||
- Requires multiple trusted contacts
|
* User enters password.
|
||||||
|
* Key derived using stored salt.
|
||||||
|
* Payload decrypted.
|
||||||
|
3. **Import**:
|
||||||
|
* Keys are imported into `SimpleE2EEService` and persisted to secure storage.
|
||||||
|
* Messages are imported into `LocalMessageStore` and re-encrypted with the device's *new* local storage key.
|
||||||
|
|
||||||
#### Option 3: Server-Side Recovery (Limited)
|
### Technical Implementation
|
||||||
- Store encrypted key backups on server
|
* **Service**: `LocalKeyBackupService` handles the encryption/decryption pipeline.
|
||||||
- Server cannot decrypt without user password
|
* **Store**: `LocalMessageStore` provides bulk export/import methods (`getAllMessageRecords`, `saveMessageRecord`).
|
||||||
- Similar to Signal's approach
|
* **UI**: `LocalBackupScreen` provides the interface for creating and restoring backups.
|
||||||
|
|
||||||
#### Option 4: Message Re-encryption
|
|
||||||
- Store messages encrypted with server keys
|
|
||||||
- Re-encrypt with new keys after recovery
|
|
||||||
- Breaks perfect forward secrecy
|
|
||||||
|
|
||||||
### Implementation Plan for Key Recovery
|
|
||||||
|
|
||||||
#### Phase 1: Key Backup
|
|
||||||
1. Add password-based key encryption
|
|
||||||
2. Implement cloud backup storage
|
|
||||||
3. Add backup/restore UI
|
|
||||||
4. Test backup/restore flow
|
|
||||||
|
|
||||||
#### Phase 2: Message Recovery
|
|
||||||
1. Store message headers for re-decryption
|
|
||||||
2. Implement batch message re-decryption
|
|
||||||
3. Add recovery progress indicators
|
|
||||||
4. Test with historical messages
|
|
||||||
|
|
||||||
#### Phase 3: Security Enhancements
|
|
||||||
1. Add backup encryption verification
|
|
||||||
2. Implement backup rotation
|
|
||||||
3. Add recovery security checks
|
|
||||||
4. Monitor recovery success rates
|
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
|
|
@ -287,10 +281,10 @@ The E2EE implementation is now fully functional with all major issues resolved.
|
||||||
- Automatic key management
|
- Automatic key management
|
||||||
- Secure message transmission
|
- Secure message transmission
|
||||||
|
|
||||||
The next phase focuses on key recovery to handle user device changes while maintaining security principles.
|
The next phase focuses on device management to handle users with multiple active devices simultaneously.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: January 30, 2026
|
**Last Updated**: February 2, 2026
|
||||||
**Status**: ✅ Production Ready (except key recovery)
|
**Status**: ✅ Production Ready (including key/message recovery)
|
||||||
**Next Priority**: Implement key recovery system
|
**Next Priority**: Device Management (Multi-device support)
|
||||||
|
|
|
||||||
395
sojorn_docs/SOJORN_ARCHITECTURE.md
Normal file
395
sojorn_docs/SOJORN_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
# Sojorn Architecture & Deployment Guide
|
||||||
|
|
||||||
|
> **Last Updated:** 2026-02-03
|
||||||
|
> **Maintainer:** MPLS LLC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Project Structure](#project-structure)
|
||||||
|
3. [Flutter App](#flutter-app)
|
||||||
|
4. [Go Backend](#go-backend)
|
||||||
|
5. [Server Deployment](#server-deployment)
|
||||||
|
6. [Database](#database)
|
||||||
|
7. [Common Commands](#common-commands)
|
||||||
|
8. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Sojorn** is a social media platform with the following core features:
|
||||||
|
- **Posts & Feeds:** Category-based content discovery
|
||||||
|
- **Chains (Threads):** Reddit-style threaded conversations
|
||||||
|
- **Beacons:** Location-based ephemeral posts
|
||||||
|
- **E2EE Chat:** End-to-end encrypted direct messaging
|
||||||
|
- **Profiles:** User profiles with follow/friend system
|
||||||
|
- **Reactions:** Custom emoji reactions on posts
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|------------|
|
||||||
|
| Mobile/Web App | Flutter (Dart) |
|
||||||
|
| Backend API | Go (Gin framework) |
|
||||||
|
| Database | PostgreSQL |
|
||||||
|
| Media Storage | Cloudflare R2 |
|
||||||
|
| Realtime | WebSockets |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\Webs\Sojorn\
|
||||||
|
├── sojorn_app/ # Flutter application
|
||||||
|
│ ├── lib/ # Dart source code
|
||||||
|
│ │ ├── screens/ # UI screens
|
||||||
|
│ │ ├── widgets/ # Reusable widgets
|
||||||
|
│ │ ├── services/ # Business logic & API calls
|
||||||
|
│ │ ├── models/ # Data models
|
||||||
|
│ │ ├── providers/ # Riverpod state management
|
||||||
|
│ │ └── theme/ # App theming
|
||||||
|
│ ├── web/ # Web-specific assets
|
||||||
|
│ ├── android/ # Android configs
|
||||||
|
│ ├── ios/ # iOS configs
|
||||||
|
│ └── pubspec.yaml # Dependencies
|
||||||
|
│
|
||||||
|
├── go-backend/ # Go API server
|
||||||
|
│ ├── cmd/api/ # Main entry point (main.go)
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── handlers/ # HTTP route handlers
|
||||||
|
│ │ ├── repository/ # Database queries
|
||||||
|
│ │ ├── services/ # Business logic
|
||||||
|
│ │ ├── models/ # Go structs
|
||||||
|
│ │ ├── middleware/ # Auth, CORS, rate limiting
|
||||||
|
│ │ └── config/ # Environment configuration
|
||||||
|
│ └── pkg/utils/ # Shared utilities
|
||||||
|
│
|
||||||
|
└── sojorn_docs/ # Documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flutter App
|
||||||
|
|
||||||
|
### Running Locally
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Webs\Sojorn\sojorn_app
|
||||||
|
|
||||||
|
# Web (Chrome)
|
||||||
|
flutter run -d chrome
|
||||||
|
|
||||||
|
# Android
|
||||||
|
flutter run -d <device-id>
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
flutter run -d <simulator-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Configuration
|
||||||
|
The app connects to the production API at:
|
||||||
|
```
|
||||||
|
https://api.gosojorn.com (or http://194.238.28.122:8080)
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration is in: `lib/config/api_config.dart`
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `lib/services/api_service.dart` | All backend API calls |
|
||||||
|
| `lib/services/auth_service.dart` | Authentication & token management |
|
||||||
|
| `lib/services/secure_chat_service.dart` | E2EE messaging |
|
||||||
|
| `lib/screens/profile/profile_screen.dart` | User's own profile |
|
||||||
|
| `lib/screens/profile/viewable_profile_screen.dart` | Viewing other profiles |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go Backend
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Webs\Sojorn\go-backend
|
||||||
|
|
||||||
|
# Run locally
|
||||||
|
go run ./cmd/api
|
||||||
|
|
||||||
|
# Build binary
|
||||||
|
go build -o sojorn-api ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Create `.env` file or set environment variables:
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://user:pass@localhost:5432/sojorn?sslmode=disable
|
||||||
|
PORT=8080
|
||||||
|
JWT_SECRET=your-secret
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
R2_ACCESS_KEY=...
|
||||||
|
R2_SECRET_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `cmd/api/main.go` | Server setup & route registration |
|
||||||
|
| `internal/handlers/post_handler.go` | Post CRUD & feed endpoints |
|
||||||
|
| `internal/handlers/user_handler.go` | Profile & social endpoints |
|
||||||
|
| `internal/repository/post_repository.go` | Post database queries |
|
||||||
|
| `internal/repository/user_repository.go` | User database queries |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Deployment
|
||||||
|
|
||||||
|
### Server Details
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **IP Address** | `194.238.28.122` |
|
||||||
|
| **SSH User** | `patrick` |
|
||||||
|
| **SSH Key** | `C:\Users\Patrick\.ssh\mpls.pem` |
|
||||||
|
| **Sudo Password** | `P22k154ever!` |
|
||||||
|
|
||||||
|
### Directory Structure on Server
|
||||||
|
```
|
||||||
|
/opt/sojorn/
|
||||||
|
├── bin/
|
||||||
|
│ └── api # ⚠️ THE RUNNING BINARY
|
||||||
|
├── go-backend/ # Git repo clone
|
||||||
|
│ └── ...
|
||||||
|
├── .env # Environment variables
|
||||||
|
└── sojorn-api # Build output (copy to bin/api)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Systemd Service
|
||||||
|
**Service Name:** `sojorn-api`
|
||||||
|
**Config File:** `/etc/systemd/system/sojorn-api.service`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Sojorn Golang API Server
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/sojorn
|
||||||
|
ExecStart=/opt/sojorn/bin/api # ⚠️ IMPORTANT: Uses bin/api
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5s
|
||||||
|
EnvironmentFile=/opt/sojorn/.env
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. SSH to server
|
||||||
|
ssh -i "C:\Users\Patrick\.ssh\mpls.pem" patrick@194.238.28.122
|
||||||
|
|
||||||
|
# 2. Pull latest code
|
||||||
|
cd /opt/sojorn/go-backend
|
||||||
|
git fetch origin
|
||||||
|
git reset --hard origin/ThreadRestoration # or main branch
|
||||||
|
|
||||||
|
# 3. Build
|
||||||
|
go build -o sojorn-api ./cmd/api
|
||||||
|
|
||||||
|
# 4. Stop service, copy binary, start service
|
||||||
|
sudo systemctl stop sojorn-api
|
||||||
|
sudo cp sojorn-api /opt/sojorn/bin/api
|
||||||
|
sudo systemctl start sojorn-api
|
||||||
|
|
||||||
|
# 5. Verify
|
||||||
|
sudo systemctl status sojorn-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Deploy Command (from Windows)
|
||||||
|
```powershell
|
||||||
|
ssh -i "C:\Users\Patrick\.ssh\mpls.pem" patrick@194.238.28.122 "cd /opt/sojorn/go-backend && git fetch origin && git reset --hard origin/ThreadRestoration && go build -o sojorn-api ./cmd/api && echo 'P22k154ever!' | sudo -S systemctl stop sojorn-api && echo 'P22k154ever!' | sudo -S cp sojorn-api /opt/sojorn/bin/api && echo 'P22k154ever!' | sudo -S systemctl start sojorn-api"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
### Connection Details
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Host** | `localhost` (from server) |
|
||||||
|
| **Port** | `5432` |
|
||||||
|
| **Database** | `sojorn` |
|
||||||
|
| **User** | `postgres` |
|
||||||
|
| **Password** | `A24Zr7AEoch4eO0N` |
|
||||||
|
|
||||||
|
### Connection String
|
||||||
|
```
|
||||||
|
postgres://postgres:A24Zr7AEoch4eO0N@localhost:5432/sojorn?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Tables
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `profiles` | User profiles |
|
||||||
|
| `posts` | All posts (regular, chains, beacons) |
|
||||||
|
| `post_metrics` | Like/comment counts |
|
||||||
|
| `post_likes` | Who liked which post |
|
||||||
|
| `post_saves` | Saved/bookmarked posts |
|
||||||
|
| `post_reactions` | Emoji reactions |
|
||||||
|
| `follows` | Follow relationships |
|
||||||
|
| `conversations` | E2EE chat conversations |
|
||||||
|
| `messages` | Chat messages |
|
||||||
|
| `categories` | Content categories |
|
||||||
|
| `category_settings` | User category preferences |
|
||||||
|
|
||||||
|
### Useful Queries
|
||||||
|
```sql
|
||||||
|
-- Check saved posts
|
||||||
|
SELECT COUNT(*) FROM post_saves;
|
||||||
|
|
||||||
|
-- Check user's saved posts
|
||||||
|
SELECT * FROM post_saves WHERE user_id = 'uuid-here';
|
||||||
|
|
||||||
|
-- Check posts table
|
||||||
|
SELECT id, author_id, body, created_at FROM posts LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Flutter App
|
||||||
|
```powershell
|
||||||
|
# Run on Chrome
|
||||||
|
flutter run -d chrome
|
||||||
|
|
||||||
|
# Build web
|
||||||
|
flutter build web
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
flutter test
|
||||||
|
|
||||||
|
# Analyze code
|
||||||
|
flutter analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go Backend
|
||||||
|
```powershell
|
||||||
|
# Run locally
|
||||||
|
cd C:\Webs\Sojorn\go-backend
|
||||||
|
go run ./cmd/api
|
||||||
|
|
||||||
|
# Build
|
||||||
|
go build -o sojorn-api ./cmd/api
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
go fmt ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Management
|
||||||
|
```bash
|
||||||
|
# SSH to server
|
||||||
|
ssh -i "C:\Users\Patrick\.ssh\mpls.pem" patrick@194.238.28.122
|
||||||
|
|
||||||
|
# Service commands
|
||||||
|
sudo systemctl status sojorn-api
|
||||||
|
sudo systemctl restart sojorn-api
|
||||||
|
sudo systemctl stop sojorn-api
|
||||||
|
sudo systemctl start sojorn-api
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
sudo journalctl -u sojorn-api -f
|
||||||
|
|
||||||
|
# Database access
|
||||||
|
PGPASSWORD=A24Zr7AEoch4eO0N psql -h localhost -U postgres -d sojorn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git Operations
|
||||||
|
```powershell
|
||||||
|
# Commit and push (from local)
|
||||||
|
cd C:\Webs\Sojorn\go-backend
|
||||||
|
git add .
|
||||||
|
git commit -m "Your message"
|
||||||
|
git push
|
||||||
|
|
||||||
|
# Pull on server
|
||||||
|
ssh ... "cd /opt/sojorn/go-backend && git pull"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Route Not Found (404) After Deployment
|
||||||
|
**Cause:** Old binary is still running.
|
||||||
|
**Solution:** Stop service before copying new binary:
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop sojorn-api
|
||||||
|
sudo cp sojorn-api /opt/sojorn/bin/api
|
||||||
|
sudo systemctl start sojorn-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Text file busy" Error
|
||||||
|
**Cause:** Trying to overwrite running binary.
|
||||||
|
**Solution:** Stop the service first.
|
||||||
|
|
||||||
|
### Server Not Responding
|
||||||
|
1. Check service status: `sudo systemctl status sojorn-api`
|
||||||
|
2. Check logs: `sudo journalctl -u sojorn-api -n 50`
|
||||||
|
3. Verify port: `sudo netstat -tlnp | grep 8080`
|
||||||
|
|
||||||
|
### Database Connection Errors
|
||||||
|
1. Check PostgreSQL: `sudo systemctl status postgresql`
|
||||||
|
2. Verify credentials in `/opt/sojorn/.env`
|
||||||
|
3. Test connection: `psql -h localhost -U postgres -d sojorn`
|
||||||
|
|
||||||
|
### Flutter Build Errors
|
||||||
|
1. Clean build: `flutter clean && flutter pub get`
|
||||||
|
2. Analyze: `flutter analyze`
|
||||||
|
3. Check Dart version: `flutter doctor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints Reference
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/v1/auth/register` | Register new user |
|
||||||
|
| POST | `/api/v1/auth/login` | Login |
|
||||||
|
| POST | `/api/v1/auth/refresh` | Refresh token |
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/profile` | Get own profile |
|
||||||
|
| GET | `/api/v1/profiles/:id` | Get profile by ID |
|
||||||
|
| PATCH | `/api/v1/profile` | Update profile |
|
||||||
|
|
||||||
|
### Posts
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/v1/posts` | Create post |
|
||||||
|
| GET | `/api/v1/posts/:id` | Get single post |
|
||||||
|
| GET | `/api/v1/feed` | Get user's feed |
|
||||||
|
| GET | `/api/v1/users/:id/posts` | Get user's posts |
|
||||||
|
| GET | `/api/v1/users/:id/saved` | Get user's saved posts |
|
||||||
|
| POST | `/api/v1/posts/:id/save` | Save a post |
|
||||||
|
| DELETE | `/api/v1/posts/:id/save` | Unsave a post |
|
||||||
|
|
||||||
|
### Social
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/v1/users/:id/follow` | Follow user |
|
||||||
|
| DELETE | `/api/v1/users/:id/follow` | Unfollow user |
|
||||||
|
| POST | `/api/v1/posts/:id/like` | Like post |
|
||||||
|
| DELETE | `/api/v1/posts/:id/like` | Unlike post |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This document should be updated whenever deployment procedures or architecture changes.*
|
||||||
36
zero_knowledge_backup_summary.md
Normal file
36
zero_knowledge_backup_summary.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Zero Knowledge Cloud Backup - Implementation Summary
|
||||||
|
|
||||||
|
## Status: COMPLETE & DEPLOYED ✅
|
||||||
|
|
||||||
|
### 1. Security Architecture (Zero Knowledge)
|
||||||
|
- **Principle**: The server never sees your private keys.
|
||||||
|
- **Cloud Backups**: Default to **Messages Only**. The payload contains encrypted message history but explicitly excludes the key pairs needed to decrypt them.
|
||||||
|
- **Local Backups**: Must be used to backup **Keys + Messages**. This is the only way to restore your identity (Subjective Identity).
|
||||||
|
- **Encryption**: All data is encrypted client-side using Argon2id (password derivation) and AES-GCM (content encryption) before leaving the device.
|
||||||
|
|
||||||
|
### 2. Frontend Implementation (Flutter)
|
||||||
|
- **Service**: Rebuilt `LocalKeyBackupService.dart` to handle the dual-mode backup logic.
|
||||||
|
- **UI**:
|
||||||
|
- Added toggle for "Cloud" vs "Local" backup.
|
||||||
|
- "Secure Mode" indicator when uploading to cloud (confirming keys are excluded).
|
||||||
|
- Explicit warning: "NOT RECOMMENDED for cloud backups. Keep keys local."
|
||||||
|
|
||||||
|
### 3. Backend Implementation (Go)
|
||||||
|
- **Endpoints**: `POST /backups/upload` and `GET /backups/download` are active.
|
||||||
|
- **Storage**: Stores opaque `encrypted_blob`, `salt`, `nonce`, and `mac`.
|
||||||
|
- **Database**:
|
||||||
|
- Applied migration `000003_e2ee_backup_recovery`.
|
||||||
|
- Created tables: `cloud_backups`, `backup_preferences`, `user_devices`.
|
||||||
|
|
||||||
|
### 4. Deployment Details
|
||||||
|
- **Server**: `194.238.28.122`
|
||||||
|
- **Service**: `sojorn-api` (Restarted at ~01:55 server time)
|
||||||
|
- **Database**: Migrations applied successfully via `migrate-linux` tool.
|
||||||
|
|
||||||
|
### 5. How to Test
|
||||||
|
1. **Create Identity Backup**: Go to "Full Backup & Recovery" -> Select "Local File" -> "Export Backup". Save this file safely!
|
||||||
|
2. **Cloud Upload**: Switch to "Cloud Backup" -> Encrypt with password -> "Upload Backup".
|
||||||
|
3. **Restore Flow**:
|
||||||
|
- Wipe app / New Device.
|
||||||
|
- **Step 1**: "Import Backup" using your Local File (Restores Identity/Keys).
|
||||||
|
- **Step 2**: "Download & Restore" from Cloud (Restores Message History).
|
||||||
Loading…
Reference in a new issue