feat: Initialize Sojorn Flutter application with core UI, services, E2EE, backup, and build scripts for various platforms.

This commit is contained in:
Patrick Britton 2026-02-03 17:13:28 -06:00
parent 78f43494a2
commit 10ae2944d2
39 changed files with 2443 additions and 819 deletions

39
cloud_backup_status.md Normal file
View 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
View 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.

View file

@ -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'])"
) )

View file

@ -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
View 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
View 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
View 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

View file

@ -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

View file

@ -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()

View file

@ -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,
}; };
} }
} }

View file

@ -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

View file

@ -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,

View file

@ -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),

View file

@ -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'),
], ],

View file

@ -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),
]), ]),
), ),
); );

View file

@ -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'),
), ),
), ),

View file

@ -425,6 +425,7 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
), ),
); );
}, },
onDelete: () => _confirmDeleteConversation(conversation),
); );
}, },
), ),
@ -432,20 +433,114 @@ 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(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Dismissible(
key: Key('conv_${widget.conversation.id}'),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) async {
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(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.cardSurface, color: AppTheme.cardSurface,
@ -459,7 +554,7 @@ class _ConversationTile extends StatelessWidget {
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
onTap: onTap, onTap: widget.onTap,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Row(
@ -472,11 +567,11 @@ class _ConversationTile extends StatelessWidget {
color: AppTheme.brightNavy.withValues(alpha: 0.1), color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
), ),
child: conversation.otherUserAvatarUrl != null child: widget.conversation.otherUserAvatarUrl != null
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
child: SignedMediaImage( child: SignedMediaImage(
url: conversation.otherUserAvatarUrl!, url: widget.conversation.otherUserAvatarUrl!,
width: 56, width: 56,
height: 56, height: 56,
fit: BoxFit.cover, fit: BoxFit.cover,
@ -484,11 +579,11 @@ class _ConversationTile extends StatelessWidget {
) )
: Center( : Center(
child: Text( child: Text(
(conversation.otherUserDisplayName ?? (widget.conversation.otherUserDisplayName ??
'@${conversation.otherUserHandle ?? 'Unknown'}') '@${widget.conversation.otherUserHandle ?? 'Unknown'}')
.isNotEmpty .isNotEmpty
? (conversation.otherUserDisplayName ?? ? (widget.conversation.otherUserDisplayName ??
'@${conversation.otherUserHandle ?? 'Unknown'}')[0] '@${widget.conversation.otherUserHandle ?? 'Unknown'}')[0]
.toUpperCase() .toUpperCase()
: '?', : '?',
style: GoogleFonts.inter( style: GoogleFonts.inter(
@ -509,8 +604,8 @@ class _ConversationTile extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
conversation.otherUserDisplayName ?? widget.conversation.otherUserDisplayName ??
'@${conversation.otherUserHandle ?? 'Unknown'}', '@${widget.conversation.otherUserHandle ?? 'Unknown'}',
style: GoogleFonts.literata( style: GoogleFonts.literata(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppTheme.navyBlue, color: AppTheme.navyBlue,
@ -519,9 +614,9 @@ class _ConversationTile extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
if (conversation.lastMessageAt != null) if (widget.conversation.lastMessageAt != null)
Text( Text(
timeago.format(conversation.lastMessageAt!), timeago.format(widget.conversation.lastMessageAt!),
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: AppTheme.textDisabled, color: AppTheme.textDisabled,
fontSize: 12, fontSize: 12,
@ -540,11 +635,11 @@ class _ConversationTile extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
conversation.lastMessageAt != null widget.conversation.lastMessageAt != null
? 'Recent message' ? 'Recent message'
: 'Start a conversation', : 'Start a conversation',
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: conversation.lastMessageAt != null color: widget.conversation.lastMessageAt != null
? AppTheme.textSecondary ? AppTheme.textSecondary
: AppTheme.textDisabled, : AppTheme.textDisabled,
fontSize: 14, fontSize: 14,
@ -558,11 +653,31 @@ class _ConversationTile extends StatelessWidget {
], ],
), ),
), ),
// 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),
),
),
),
], ],
), ),
), ),
), ),
), ),
),
),
); );
} }
} }

View file

@ -360,7 +360,32 @@ class _SecureChatScreenState extends State<SecureChatScreen>
label: dateLabel, label: dateLabel,
), ),
), ),
ChatBubbleWidget( Dismissible(
key: ValueKey('swipe-${current.id}-${current.isPending}'),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) async {
if (current.isPending) {
_removePending(current.id);
} else {
_confirmDeleteMessage(current.id, forEveryone: false);
}
return false; // Let confirmation dialog handle it
},
background: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: AppTheme.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
child: Icon(
Icons.delete_outline,
color: AppTheme.error,
size: 24,
),
),
child: ChatBubbleWidget(
key: ValueKey('${current.id}-${current.isPending}'), key: ValueKey('${current.id}-${current.isPending}'),
message: current.text, message: current.text,
isMe: current.isMe, isMe: current.isMe,
@ -390,6 +415,7 @@ class _SecureChatScreenState extends State<SecureChatScreen>
), ),
onReply: () => _startReply(current), onReply: () => _startReply(current),
), ),
),
], ],
); );
}, },

View file

@ -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,
},
);
}
} }

View file

@ -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')
final keyData = payloadData.containsKey('keys') ? payloadData['keys'] : payloadData;
// 5. Import keys to E2EE service (if present)
if (keyData != null) {
await _importAllKeys(keyData, e2eeService); 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,
), ),
); );

View file

@ -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 {

View file

@ -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'); if (keys.containsKey('identity_signing_private')) {
print('[E2EE] Restoring Identity Signing key...');
_identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(keys['identity_signing_private']));
}
// Generate new identity keys // 2. Restore Signed PreKey
_identityDhKeyPair = await _dhAlgo.newKeyPair(); if (keys.containsKey('signed_prekey_private')) {
_identitySigningKeyPair = await _signingAlgo.newKeyPair(); print('[E2EE] Restoring Signed PreKey...');
_signedPreKey = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['signed_prekey_private']));
}
// Generate new signed prekey // 3. Restore One-Time PreKeys
_signedPreKey = await _dhAlgo.newKeyPair(); if (keys.containsKey('one_time_prekeys') && keys['one_time_prekeys'] is List) {
final spkPublic = await _signedPreKey!.extractPublicKey(); final otkList = keys['one_time_prekeys'] as List;
final spkSignature = await _signingAlgo.sign(spkPublic.bytes, keyPair: _identitySigningKeyPair!);
// Generate new OTKs
final importedOTKs = <SimpleKeyPair>[]; final importedOTKs = <SimpleKeyPair>[];
for (int i = 0; i < 20; i++) { for (final item in otkList) {
importedOTKs.add(await _dhAlgo.newKeyPair()); if (item is Map && item.containsKey('private_key')) {
importedOTKs.add(await _dhAlgo.newKeyPairFromSeed(base64Decode(item['private_key'])));
}
} }
_oneTimePreKeys = importedOTKs; _oneTimePreKeys = importedOTKs;
print('[E2EE] Restored ${_oneTimePreKeys!.length} OTKs');
// Save locally and republish to server
if (_initializedForUserId != null) {
await _saveKeysToLocal(_initializedForUserId!);
await _publishKeys(spkSignature.bytes);
} }
print('[E2EE] New keys generated and imported successfully'); // 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) {
print('[E2EE] Persisting restored keys to local storage...');
await _saveKeysToLocal(_initializedForUserId!);
// 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] 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;
} }
} }

View file

@ -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,

View file

@ -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,
),
],
], ],
), ),
), ),

View file

@ -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,
),
),
], ],
), ),
), ),

View file

@ -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)

View file

@ -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 {

View file

@ -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,8 +48,10 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
_loadReactionSets(); _loadReactionSets();
} }
Future<void> _loadReactionSets() async { Future<void> _loadReactionSets() async {
// Start with emoji set try {
final reactionSets = <String, List<String>>{ final reactionSets = <String, List<String>>{
'emoji': [ 'emoji': [
'❤️', '👍', '😂', '😮', '😢', '😡', '❤️', '👍', '😂', '😮', '😢', '😡',
@ -57,162 +62,92 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
}; };
final folderCredits = <String, String>{}; final folderCredits = <String, String>{};
final tabOrder = ['emoji']; // Start with emoji, will add folders after final tabOrder = ['emoji'];
// Known reaction folders to check // Load the manifest to discover assets
final knownFolders = ['dotto', 'green', 'blue', 'purple']; final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final assetPaths = manifest.listAssets();
for (final folder in knownFolders) { // Filter for reaction assets
final reactionAssets = assetPaths.where((path) {
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 { try {
// Try to load credit file final creditPath = 'assets/reactions/$folderName/credit.md';
final creditContent = await _loadCreditFile(folder); // Check if credit file exists in manifest too
folderCredits[folder] = creditContent; if (assetPaths.contains(creditPath)) {
final creditData = await rootBundle.loadString(creditPath);
// Try to load files from this folder folderCredits[folderName] = creditData;
final reactions = await _loadAllFilesFromFolder(folder);
if (reactions.isNotEmpty) {
reactionSets[folder] = reactions;
tabOrder.add(folder);
} }
} catch (e) { } catch (e) {
// Error loading folder, skip it // 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(() { setState(() {
_reactionSets = reactionSets; _reactionSets = reactionSets;
_folderCredits = folderCredits; _folderCredits = folderCredits;
_tabOrder = tabOrder; _tabOrder = tabOrder;
_isLoading = false; _isLoading = false;
// Create TabController after setting up the data
_tabController = TabController(length: _tabOrder.length, vsync: this); _tabController = TabController(length: _tabOrder.length, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
if (mounted) {
setState(() { setState(() {
_currentTabIndex = _tabController.index; _currentTabIndex = _tabController.index;
_clearSearch(); _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) { } catch (e) {
// File doesn't exist, try next extension print('[REACTIONS] Error scanning assets: $e');
// Fallback
if (mounted) {
setState(() {
_reactionSets = {
'emoji': ['❤️', '👍', '😂', '😮', '😢', '😡']
};
_tabOrder = ['emoji'];
_isLoading = false;
_tabController = TabController(length: 1, vsync: this);
});
} }
} }
} }
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(
line.substring(2).trim(),
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
));
} 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)); styleSheet: MarkdownStyleSheet(
} p: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
h1: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
return Column( h2: GoogleFonts.inter(fontSize: 11, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
crossAxisAlignment: CrossAxisAlignment.start, listBullet: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
children: widgets, 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),
),
); );
} }
@ -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,

View file

@ -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();

View file

@ -1,71 +1,170 @@
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,
count: entry.value,
isSelected: myReactions.contains(entry.key),
tooltipNames: reactionUsers?[entry.key],
onTap: () => onToggleReaction?.call(entry.key),
);
}),
if (onAddReaction != null)
_ReactionAddButton(onTap: onAddReaction!),
],
),
);
}
}
return GestureDetector( class _ReactionChip extends StatefulWidget {
onTap: onReactionTap, final String reactionId;
child: Container( final int count;
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 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( decoration: BoxDecoration(
color: isMyReaction color: isMyReaction
? AppTheme.brightNavy.withValues(alpha: 0.15) ? AppTheme.brightNavy.withValues(alpha: 0.15)
: AppTheme.navyBlue.withValues(alpha: 0.08), : AppTheme.navyBlue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
border: isMyReaction border: isMyReaction
? Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.3)) ? Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.3))
: null, : null,
boxShadow: isMyReaction
? [
BoxShadow(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( _ReactionIcon(reactionId: widget.reactionId, size: 18),
emoji, if (widget.count > 0) ...[
style: const TextStyle(fontSize: 14),
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
count > 99 ? '99+' : '$count', widget.count > 99 ? '99+' : '${widget.count}',
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: isMyReaction ? AppTheme.brightNavy : AppTheme.textSecondary, color: isMyReaction ? AppTheme.brightNavy : AppTheme.textSecondary,
fontSize: 12, fontSize: 12,
@ -73,11 +172,85 @@ class ReactionsDisplay extends StatelessWidget {
), ),
), ),
], ],
],
), ),
), ),
)
.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,
); );
}).toList(), }
}
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),
);
}
}

View file

@ -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',

View file

@ -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,
); );

View file

@ -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"

View file

@ -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

View file

@ -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 -->

View file

@ -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": [

View file

@ -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)

View 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.*

View 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).