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
}
$required = @('SUPABASE_URL', 'SUPABASE_ANON_KEY', 'API_BASE_URL')
$required = @('API_BASE_URL')
$missing = $required | Where-Object {
-not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_])
}
@ -37,8 +37,6 @@ if ($missing.Count -gt 0) {
}
$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'])"
)

View file

@ -1,6 +1,6 @@
param(
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
[int]$Port = 8000,
[int]$Port = 8001,
[string]$Renderer = "auto", # Options: auto, canvaskit, html
[switch]$NoWasmDryRun
)
@ -8,7 +8,9 @@ param(
function Parse-Env($path) {
$vals = @{}
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
}
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'])) {
$defineArgs += "--dart-define=API_BASE_URL=https://api.gosojorn.com/api/v1"
$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
}
}
@ -60,7 +66,7 @@ try {
$cmdArgs = @(
'run',
'-d',
'chrome',
'edge',
'--web-hostname',
'localhost',
'--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
**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
**Source:** [Dotto Emoji](https://github.com/meritite-union/dotto-emoji) by meritite-union

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 DateTime createdAt;
final Profile? author;
final Map<String, int>? reactions;
final List<String>? myReactions;
const PostPreview({
required this.id,
required this.body,
required this.createdAt,
this.author,
this.reactions,
this.myReactions,
});
factory PostPreview.fromJson(Map<String, dynamic> json) {
@ -345,6 +349,8 @@ class PostPreview {
body: json['body'] as String? ?? '',
createdAt: DateTime.parse(json['created_at'] as String),
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,
createdAt: post.createdAt,
author: post.author,
reactions: post.reactions,
myReactions: post.myReactions,
);
}
@ -363,6 +371,8 @@ class PostPreview {
'body': body,
'created_at': createdAt.toIso8601String(),
'author': author?.toJson(),
'reactions': reactions,
'my_reactions': myReactions,
};
}
}

View file

@ -251,7 +251,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
// Terms and privacy note
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(
// Replaced AppTheme.bodySmall
color: AppTheme

View file

@ -169,14 +169,10 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
PreferredSizeWidget _buildAppBar() {
return AppBar(
title: Text(
'sojorn',
style: GoogleFonts.literata(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: AppTheme.navyBlue,
),
title: Image.asset(
'assets/images/toplogo.png',
height: 38,
fit: BoxFit.contain,
),
centerTitle: false,
elevation: 0,

View file

@ -6,7 +6,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../../widgets/reactions/reaction_picker.dart';
import '../../widgets/reactions/reaction_strip.dart';
import '../../widgets/reactions/reactions_display.dart';
import '../../models/post.dart';
import '../../providers/api_provider.dart';
import '../../theme/app_theme.dart';
@ -387,6 +387,15 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
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,
children: [
// Reactions section - full width like main post
ReactionStrip(
reactions: _reactionCountsFor(post),
ReactionsDisplay(
reactionCounts: _reactionCountsFor(post),
myReactions: _myReactionsFor(post),
reactionUsers: _reactionUsersFor(post),
onToggle: (emoji) => _toggleReaction(post.id, emoji),
onAdd: () => _openReactionPicker(post.id),
onToggleReaction: (emoji) => _toggleReaction(post.id, emoji),
onAddReaction: () => _openReactionPicker(post.id),
mode: ReactionsDisplayMode.full,
),
const SizedBox(height: 16),
// Actions row - left aligned
@ -869,21 +879,20 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
Row(
children: [
// Compact reactions display
if (_reactionCountsFor(post).isNotEmpty) ...[
Icon(
Icons.favorite_border,
size: 12,
color: AppTheme.textSecondary,
),
const SizedBox(width: 4),
Text(
'${_reactionCountsFor(post).values.reduce((a, b) => a + b)}',
style: GoogleFonts.inter(
color: AppTheme.textSecondary,
fontSize: 10,
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: ReactionsDisplay(
reactionCounts: _reactionCountsFor(post),
myReactions: _myReactionsFor(post),
onToggleReaction: (emoji) => _toggleReaction(post.id, emoji),
onAddReaction: () => _openReactionPicker(post.id),
mode: ReactionsDisplayMode.compact,
padding: EdgeInsets.zero,
),
),
],
),
const Spacer(),
Container(
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 'profile_settings_screen.dart';
enum ProfileFeedType { posts, appreciated, saved, chained }
enum ProfileFeedType { posts, saved, chained }
/// Premium profile screen with NestedScrollView and SliverAppBar
class ProfileScreen extends ConsumerStatefulWidget {
@ -55,11 +55,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
bool _hasMorePosts = true;
String? _postsError;
List<Post> _appreciatedPosts = [];
bool _isAppreciatedLoading = false;
bool _isAppreciatedLoadingMore = false;
bool _hasMoreAppreciated = true;
String? _appreciatedError;
List<Post> _savedPosts = [];
bool _isSavedLoading = false;
@ -76,7 +72,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
setState(() {
@ -231,8 +227,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
switch (_activeFeed) {
case ProfileFeedType.posts:
return _loadPosts(refresh: refresh);
case ProfileFeedType.appreciated:
return _loadAppreciated(refresh: refresh);
case ProfileFeedType.saved:
return _loadSaved(refresh: refresh);
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 {
if (_profile == null) return;
@ -754,8 +693,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
switch (type) {
case ProfileFeedType.posts:
return _posts;
case ProfileFeedType.appreciated:
return _appreciatedPosts;
case ProfileFeedType.saved:
return _savedPosts;
case ProfileFeedType.chained:
@ -767,8 +704,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
switch (type) {
case ProfileFeedType.posts:
return _isPostsLoading;
case ProfileFeedType.appreciated:
return _isAppreciatedLoading;
case ProfileFeedType.saved:
return _isSavedLoading;
case ProfileFeedType.chained:
@ -780,8 +715,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
switch (type) {
case ProfileFeedType.posts:
return _isPostsLoadingMore;
case ProfileFeedType.appreciated:
return _isAppreciatedLoadingMore;
case ProfileFeedType.saved:
return _isSavedLoadingMore;
case ProfileFeedType.chained:
@ -793,8 +726,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
switch (type) {
case ProfileFeedType.posts:
return _hasMorePosts;
case ProfileFeedType.appreciated:
return _hasMoreAppreciated;
case ProfileFeedType.saved:
return _hasMoreSaved;
case ProfileFeedType.chained:
@ -806,8 +737,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
switch (type) {
case ProfileFeedType.posts:
return _postsError;
case ProfileFeedType.appreciated:
return _appreciatedError;
case ProfileFeedType.saved:
return _savedError;
case ProfileFeedType.chained:
@ -815,6 +744,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
}
}
@override
Widget build(BuildContext context) {
final profile = _profile;
@ -910,7 +840,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
labelStyle: AppTheme.labelMedium,
tabs: const [
Tab(text: 'Posts'),
Tab(text: 'Appreciated'),
Tab(text: 'Saved'),
Tab(text: 'Chains'),
],

View file

@ -335,7 +335,6 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
);
},
),
const SizedBox(height: AppTheme.spacingLg),
Align(
alignment: Alignment.centerRight,
child: TextButton(
@ -346,6 +345,34 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
width: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: 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;
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
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController = TabController(length: 4, vsync: this);
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
setState(() {
_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 {
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 {
if (_isPrivacyLoading) return;
@ -616,6 +758,8 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
labelStyle: AppTheme.labelMedium,
tabs: const [
Tab(text: 'Posts'),
Tab(text: 'Saved'),
Tab(text: 'Chains'),
Tab(text: 'About'),
],
),
@ -624,33 +768,77 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
}
Widget _buildTabBarView() {
if (_activeTab == 1) {
if (_activeTab == 3) {
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(
onRefresh: () => _loadPosts(refresh: true),
onRefresh: () async => onRefresh(),
child: CustomScrollView(
slivers: [
if (_postsError != null)
if (error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingLg),
child: Text(
_postsError!,
error,
style: AppTheme.bodyMedium.copyWith(color: AppTheme.error),
textAlign: TextAlign.center,
),
),
),
if (_isPostsLoading && _posts.isEmpty)
if (isLoading && posts.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
child: Center(child: CircularProgressIndicator()),
),
),
if (_posts.isEmpty && !_isPostsLoading)
if (posts.isEmpty && !isLoading)
SliverFillRemaining(
child: Center(
child: Text(
@ -661,7 +849,7 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
),
),
),
if (_posts.isNotEmpty)
if (posts.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingMd,
@ -670,10 +858,10 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final post = _posts[index];
final post = posts[index];
return Padding(
padding: EdgeInsets.only(
bottom: index == _posts.length - 1
bottom: index == posts.length - 1
? 0
: AppTheme.spacingSm,
),
@ -684,22 +872,22 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
),
);
},
childCount: _posts.length,
childCount: posts.length,
),
),
),
if (_isPostsLoadingMore)
if (isLoadingMore)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
child: Center(child: CircularProgressIndicator()),
),
),
if (!_isPostsLoadingMore && _hasMorePosts && _posts.isNotEmpty)
if (!isLoadingMore && hasMore && posts.isNotEmpty)
SliverToBoxAdapter(
child: Center(
child: TextButton(
onPressed: () => _loadPosts(refresh: false),
onPressed: onLoadMore,
child: const Text('Load more'),
),
),

View file

@ -425,6 +425,7 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
),
);
},
onDelete: () => _confirmDeleteConversation(conversation),
);
},
),
@ -432,133 +433,247 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
},
);
}
Future<void> _confirmDeleteConversation(SecureConversation conversation) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppTheme.cardSurface,
title: Text(
'Delete Conversation?',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
),
),
content: Text(
'Are you sure you want to delete this conversation with ${conversation.otherUserDisplayName ?? conversation.otherUserHandle}? This will remove all messages for everyone.',
style: GoogleFonts.inter(color: AppTheme.navyText),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancel',
style: GoogleFonts.inter(color: AppTheme.egyptianBlue),
),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.error,
foregroundColor: Colors.white,
),
child: Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
await _chatService.deleteConversation(conversation.id, fullDelete: true);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Conversation deleted'),
backgroundColor: Colors.green,
),
);
_loadConversations();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to delete: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
}
class _ConversationTile extends StatelessWidget {
class _ConversationTile extends StatefulWidget {
final SecureConversation conversation;
final VoidCallback onTap;
final VoidCallback onDelete;
const _ConversationTile({
required this.conversation,
required this.onTap,
required this.onDelete,
});
@override
State<_ConversationTile> createState() => _ConversationTileState();
}
class _ConversationTileState extends State<_ConversationTile> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
width: 1,
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: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Avatar
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(28),
),
child: conversation.otherUserAvatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(28),
child: SignedMediaImage(
url: conversation.otherUserAvatarUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
),
)
: Center(
child: Text(
(conversation.otherUserDisplayName ??
'@${conversation.otherUserHandle ?? 'Unknown'}')
.isNotEmpty
? (conversation.otherUserDisplayName ??
'@${conversation.otherUserHandle ?? 'Unknown'}')[0]
.toUpperCase()
: '?',
style: GoogleFonts.inter(
color: AppTheme.navyBlue,
fontWeight: FontWeight.bold,
fontSize: 20,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
width: 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: widget.onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Avatar
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(28),
),
child: widget.conversation.otherUserAvatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(28),
child: SignedMediaImage(
url: widget.conversation.otherUserAvatarUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
),
)
: Center(
child: Text(
(widget.conversation.otherUserDisplayName ??
'@${widget.conversation.otherUserHandle ?? 'Unknown'}')
.isNotEmpty
? (widget.conversation.otherUserDisplayName ??
'@${widget.conversation.otherUserHandle ?? 'Unknown'}')[0]
.toUpperCase()
: '?',
style: GoogleFonts.inter(
color: AppTheme.navyBlue,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
),
),
const SizedBox(width: 16),
// Content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
widget.conversation.otherUserDisplayName ??
'@${widget.conversation.otherUserHandle ?? 'Unknown'}',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
if (widget.conversation.lastMessageAt != null)
Text(
timeago.format(widget.conversation.lastMessageAt!),
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.lock,
size: 12,
color: AppTheme.brightNavy.withValues(alpha: 0.5),
),
const SizedBox(width: 4),
Expanded(
child: Text(
widget.conversation.lastMessageAt != null
? 'Recent message'
: 'Start a conversation',
style: GoogleFonts.inter(
color: widget.conversation.lastMessageAt != null
? AppTheme.textSecondary
: AppTheme.textDisabled,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
],
),
),
// Hover delete button for web/desktop
if (_isHovered)
Padding(
padding: const EdgeInsets.only(left: 8),
child: IconButton(
onPressed: widget.onDelete,
icon: const Icon(
Icons.delete_outline,
color: AppTheme.error,
size: 20,
),
tooltip: 'Delete conversation',
style: IconButton.styleFrom(
backgroundColor: AppTheme.error.withValues(alpha: 0.1),
padding: const EdgeInsets.all(8),
),
),
),
const SizedBox(width: 16),
// Content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
conversation.otherUserDisplayName ??
'@${conversation.otherUserHandle ?? 'Unknown'}',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
if (conversation.lastMessageAt != null)
Text(
timeago.format(conversation.lastMessageAt!),
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.lock,
size: 12,
color: AppTheme.brightNavy.withValues(alpha: 0.5),
),
const SizedBox(width: 4),
Expanded(
child: Text(
conversation.lastMessageAt != null
? 'Recent message'
: 'Start a conversation',
style: GoogleFonts.inter(
color: conversation.lastMessageAt != null
? AppTheme.textSecondary
: AppTheme.textDisabled,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
],
),
],
),
],
),
),
),
),

View file

@ -360,35 +360,61 @@ class _SecureChatScreenState extends State<SecureChatScreen>
label: dateLabel,
),
),
ChatBubbleWidget(
key: ValueKey('${current.id}-${current.isPending}'),
message: current.text,
isMe: current.isMe,
timestamp: current.timestamp,
isSending: current.isPending && !current.sendFailed,
sendFailed: current.sendFailed,
isDelivered: current.isDelivered,
isRead: current.isRead,
decryptionFailed: current.decryptionFailed,
isFirstInCluster: startsCluster,
isLastInCluster: endsCluster,
showAvatar: true,
avatarUrl: current.isMe
? _currentUserAvatarUrl
: _otherUserAvatarUrl,
avatarInitial: current.isMe
? _currentUserInitial
: _otherUserInitial,
onLongPress: current.isPending
? null
: () => _showMessageOptions(current),
onDelete: current.isPending
? () => _removePending(current.id)
: () => _confirmDeleteMessage(
current.id,
forEveryone: false,
),
onReply: () => _startReply(current),
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}'),
message: current.text,
isMe: current.isMe,
timestamp: current.timestamp,
isSending: current.isPending && !current.sendFailed,
sendFailed: current.sendFailed,
isDelivered: current.isDelivered,
isRead: current.isRead,
decryptionFailed: current.decryptionFailed,
isFirstInCluster: startsCluster,
isLastInCluster: endsCluster,
showAvatar: true,
avatarUrl: current.isMe
? _currentUserAvatarUrl
: _otherUserAvatarUrl,
avatarInitial: current.isMe
? _currentUserInitial
: _otherUserInitial,
onLongPress: current.isPending
? null
: () => _showMessageOptions(current),
onDelete: current.isPending
? () => _removePending(current.id)
: () => _confirmDeleteMessage(
current.id,
forEveryone: false,
),
onReply: () => _startReply(current),
),
),
],
);

View file

@ -17,13 +17,12 @@ import '../models/tone_analysis.dart';
import '../utils/security_utils.dart';
import '../utils/request_signing.dart';
import 'package:http/http.dart' as http;
/// ApiService - Single source of truth for all backend communication.
class ApiService {
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
static ApiService? _instance;
@ -146,9 +145,6 @@ class ApiService {
return {
'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,
int limit = 20,
int offset = 0,
bool onlyChains = false,
}) async {
final data = await _callGoApi(
'/users/$authorId/posts',
@ -435,6 +432,7 @@ class ApiService {
queryParams: {
'limit': limit.toString(),
'offset': offset.toString(),
if (onlyChains) 'chained': 'true',
},
);
@ -477,7 +475,7 @@ class ApiService {
int offset = 0,
}) async {
final data = await _callGoApi(
'/users/me/saved',
'/users/$userId/saved',
method: 'GET',
queryParams: {'limit': '$limit', 'offset': '$offset'},
);
@ -490,7 +488,12 @@ class ApiService {
int limit = 20,
int offset = 0,
}) 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({
@ -1091,4 +1094,92 @@ class ApiService {
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:universal_html/html.dart' as html;
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/local_message_store.dart';
import '../../services/api_service.dart';
import '../../services/auth_service.dart';
import '../../theme/app_theme.dart';
/// Local key backup service for device-based key storage
@ -29,12 +33,45 @@ class LocalKeyBackupService {
static Future<Map<String, dynamic>> createEncryptedBackup({
required String password,
required SimpleE2EEService e2eeService,
bool includeKeys = true,
bool includeMessages = true,
}) async {
try {
print('[BACKUP] Creating encrypted backup...');
print('[BACKUP] Creating encrypted backup (keys: $includeKeys, msgs: $includeMessages)...');
// 1. Export all keys from E2EE service
final keyData = await _exportAllKeys(e2eeService);
// 1. Export keys (if requested)
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
final salt = _generateSalt();
@ -47,7 +84,7 @@ class LocalKeyBackupService {
final secretKey = SecretKey(encryptionKey);
final nonce = _generateNonce();
final plaintext = utf8.encode(jsonEncode(keyData));
final plaintext = utf8.encode(jsonEncode(payloadData));
final secretBox = await algorithm.encrypt(
plaintext,
secretKey: secretKey,
@ -65,7 +102,8 @@ class LocalKeyBackupService {
'metadata': {
'app_name': 'Sojorn',
'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 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
await _importAllKeys(keyData, e2eeService);
// 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);
}
// 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');
return {
'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'],
};
@ -331,9 +397,79 @@ class LocalKeyBackupService {
}
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']}');
}
}
/// 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
@ -348,6 +484,9 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
final SimpleE2EEService _e2eeService = SimpleE2EEService();
bool _isCreatingBackup = false;
bool _isRestoringBackup = false;
bool _includeMessages = true;
bool _includeKeys = true;
bool _useCloud = false; // Toggle for Cloud vs Local
String? _lastBackupPath;
DateTime? _lastBackupDate;
@ -360,7 +499,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
elevation: 0,
surfaceTintColor: Colors.transparent,
title: Text(
'Key Backup',
'Full Backup & Recovery',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
@ -375,6 +514,8 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
children: [
_buildInfoCard(),
const SizedBox(height: 24),
_buildModeToggle(),
const SizedBox(height: 24),
_buildCreateBackupSection(),
const SizedBox(height: 24),
_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() {
return Container(
padding: const EdgeInsets.all(16),
@ -405,7 +593,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
Icon(Icons.info_outline, color: AppTheme.brightNavy),
const SizedBox(width: 8),
Text(
'Local Key Backup',
_useCloud ? 'Encrypted Cloud Backup' : 'Local Key Backup',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.brightNavy,
@ -416,8 +604,11 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
),
const SizedBox(height: 8),
Text(
'Your encryption keys are saved locally on this device with password protection. '
'You can export them to a file for safekeeping or restore from a backup file.',
_useCloud
? '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(
color: AppTheme.textSecondary,
fontSize: 14,
@ -442,7 +633,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
Icon(Icons.backup, color: AppTheme.brightNavy),
const SizedBox(width: 8),
Text(
'Create Backup',
_useCloud ? 'Upload to Cloud' : 'Create Backup',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
@ -453,17 +644,72 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
),
const SizedBox(height: 12),
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(
color: AppTheme.textSecondary,
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),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isCreatingBackup ? null : _createBackup,
onPressed: _isCreatingBackup ? null : (_useCloud ? _createCloudBackup : _createBackup),
icon: _isCreatingBackup
? SizedBox(
width: 16,
@ -473,8 +719,8 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Icon(Icons.file_download),
label: Text(_isCreatingBackup ? 'Creating...' : 'Export Backup'),
: Icon(_useCloud ? Icons.cloud_upload : Icons.file_download),
label: Text(_isCreatingBackup ? 'Processing...' : (_useCloud ? 'Upload Backup' : 'Export Backup')),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
foregroundColor: Colors.white,
@ -501,7 +747,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
Icon(Icons.restore, color: AppTheme.brightNavy),
const SizedBox(width: 8),
Text(
'Restore Backup',
_useCloud ? 'Download & Restore' : 'Restore Backup',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
@ -512,7 +758,9 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
),
const SizedBox(height: 12),
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(
color: AppTheme.textSecondary,
fontSize: 14,
@ -522,7 +770,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isRestoringBackup ? null : _restoreBackup,
onPressed: _isRestoringBackup ? null : (_useCloud ? _restoreCloudBackup : _restoreBackup),
icon: _isRestoringBackup
? SizedBox(
width: 16,
@ -532,8 +780,8 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Icon(Icons.file_upload),
label: Text(_isRestoringBackup ? 'Restoring...' : 'Import Backup'),
: Icon(_useCloud ? Icons.cloud_download : Icons.file_upload),
label: Text(_isRestoringBackup ? 'Restoring...' : (_useCloud ? 'Download & Restore' : 'Import Backup')),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.royalPurple,
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 {
try {
setState(() => _isCreatingBackup = true);
@ -602,6 +922,8 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
final backup = await LocalKeyBackupService.createEncryptedBackup(
password: password,
e2eeService: _e2eeService,
includeMessages: _includeMessages,
includeKeys: _includeKeys, // Should be true for local
);
// Save to device
@ -656,7 +978,7 @@ class _LocalBackupScreenState extends State<LocalBackupScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
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,
),
);

View file

@ -384,6 +384,44 @@ class LocalMessageStore {
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.
Future<List<String>> getMessageIdsForConversation(String conversationId) async {
try {

View file

@ -658,7 +658,7 @@ class SimpleE2EEService {
// Automatic MAC error handling
int _macErrorCount = 0;
static const int _maxMacErrors = 3;
static const int _maxMacErrors = 50;
DateTime? _lastMacErrorTime;
void _handleMacError() {
@ -949,6 +949,7 @@ class SimpleE2EEService {
otkData.add({
'key_id': i,
'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>;
// For now, we'll generate new keys since the cryptography library
// doesn't support direct private key import
// In a production app, you'd need a more sophisticated key import system
print('[E2EE] Note: Generating new keys due to import limitations');
// Generate new identity keys
_identityDhKeyPair = await _dhAlgo.newKeyPair();
_identitySigningKeyPair = await _signingAlgo.newKeyPair();
// Generate new signed prekey
_signedPreKey = await _dhAlgo.newKeyPair();
final spkPublic = await _signedPreKey!.extractPublicKey();
final spkSignature = await _signingAlgo.sign(spkPublic.bytes, keyPair: _identitySigningKeyPair!);
// Generate new OTKs
final importedOTKs = <SimpleKeyPair>[];
for (int i = 0; i < 20; i++) {
importedOTKs.add(await _dhAlgo.newKeyPair());
// 1. Restore Identity Keys
if (keys.containsKey('identity_dh_private')) {
print('[E2EE] Restoring Identity DH key...');
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['identity_dh_private']));
}
_oneTimePreKeys = importedOTKs;
// Save locally and republish to server
if (keys.containsKey('identity_signing_private')) {
print('[E2EE] Restoring Identity Signing key...');
_identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(keys['identity_signing_private']));
}
// 2. Restore Signed PreKey
if (keys.containsKey('signed_prekey_private')) {
print('[E2EE] Restoring Signed PreKey...');
_signedPreKey = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['signed_prekey_private']));
}
// 3. Restore One-Time PreKeys
if (keys.containsKey('one_time_prekeys') && keys['one_time_prekeys'] is List) {
final otkList = keys['one_time_prekeys'] as List;
final importedOTKs = <SimpleKeyPair>[];
for (final item in otkList) {
if (item is Map && item.containsKey('private_key')) {
importedOTKs.add(await _dhAlgo.newKeyPairFromSeed(base64Decode(item['private_key'])));
}
}
_oneTimePreKeys = importedOTKs;
print('[E2EE] Restored ${_oneTimePreKeys!.length} OTKs');
}
// 4. Set User Context from metadata
if (backupData.containsKey('metadata')) {
final metadata = backupData['metadata'] as Map<String, dynamic>;
if (metadata.containsKey('user_id')) {
_initializedForUserId = metadata['user_id'];
}
}
// Fallback if metadata missing
if (_initializedForUserId == null) {
_initializedForUserId = _auth.currentUser?.id;
}
// 5. Persist and Synchronize
if (_initializedForUserId != null) {
print('[E2EE] Persisting restored keys to local storage...');
await _saveKeysToLocal(_initializedForUserId!);
await _publishKeys(spkSignature.bytes);
// Republish to server to ensure backend is synchronized
// This is safe even if keys are identical
if (_identitySigningKeyPair != null && _signedPreKey != null) {
final spkPublic = await _signedPreKey!.extractPublicKey();
final signature = await _signingAlgo.sign(
spkPublic.bytes,
keyPair: _identitySigningKeyPair!
);
await _publishKeys(signature.bytes);
}
}
print('[E2EE] New keys generated and imported successfully');
print('[E2EE] Backup restoration complete. Old messages can now be decrypted.');
} catch (e) {
print('[E2EE] Failed to import keys: $e');
print('[E2EE] CRITICAL: Failed to import keys: $e');
rethrow;
}
}

View file

@ -80,7 +80,9 @@ class AppScaffold extends StatelessWidget {
PreferredSizeWidget _buildDefaultAppBar(BuildContext context) {
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,
leading: leading ?? _buildBackButton(context),
actions: actions,

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../models/post.dart';
import '../theme/app_theme.dart';
import 'media/signed_media_image.dart';
import 'reactions/reactions_display.dart';
class ChainQuoteWidget extends StatelessWidget {
final PostPreview parent;
@ -131,6 +132,15 @@ class ChainQuoteWidget extends StatelessWidget {
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 '../theme/app_theme.dart';
import '../widgets/media/signed_media_image.dart';
import '../widgets/reactions/reactions_display.dart';
/// Kinetic Spatial Engine widget for layer-based thread navigation
class KineticThreadWidget extends ConsumerStatefulWidget {
@ -1011,6 +1012,16 @@ class _KineticThreadWidgetState extends ConsumerState<KineticThreadWidget>
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 '../sojorn_snackbar.dart';
import '../reactions/reaction_picker.dart';
import '../reactions/smart_reaction_button.dart';
import '../reactions/reactions_display.dart';
/// Post actions with a vibrant, clear, and energetic design.
@ -51,7 +50,18 @@ class _PostActionsState extends ConsumerState<PostActions> {
_seedReactionState();
}
@override
void didUpdateWidget(covariant PostActions oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.post != oldWidget.post) {
_isSaved = widget.post.isSaved ?? false;
_seedReactionState();
}
}
void _seedReactionState() {
_reactionCounts.clear();
_myReactions.clear();
if (widget.post.reactions != null) {
_reactionCounts.addAll(widget.post.reactions!);
}
@ -207,13 +217,13 @@ class _PostActionsState extends ConsumerState<PostActions> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Show all reactions in thread view
if (widget.showReactions && _reactionCounts.isNotEmpty)
ReactionsDisplay(
reactionCounts: _reactionCounts,
myReactions: _myReactions,
onReactionTap: _showReactionPicker,
showAll: true,
onToggleReaction: _toggleReaction,
onAddReaction: _showReactionPicker,
mode: ReactionsDisplayMode.full,
),
// Actions row - reply moved to right
@ -256,11 +266,13 @@ class _PostActionsState extends ConsumerState<PostActions> {
// Right side: Reply and Reactions
Row(
children: [
// Smart reaction button (replaces appreciate button)
SmartReactionButton(
// Single Authority: ReactionsDisplay in compact mode for the actions row
ReactionsDisplay(
reactionCounts: _reactionCounts,
myReactions: _myReactions,
onPressed: _showReactionPicker,
onToggleReaction: _toggleReaction,
onAddReaction: _showReactionPicker,
mode: ReactionsDisplayMode.compact,
),
const SizedBox(width: 8),
if (allowChain)

View file

@ -3,9 +3,9 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../../models/post.dart';
import '../../theme/app_theme.dart';
import '../media/signed_media_image.dart';
import '../video_thumbnail_widget.dart';
import '../post/post_actions.dart';
import 'media/signed_media_image.dart';
import 'video_thumbnail_widget.dart';
import 'post/post_actions.dart';
/// Enhanced post widget with video thumbnail support (Twitter-style)
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/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:convert';
import '../../theme/app_theme.dart';
class ReactionPicker extends StatefulWidget {
@ -45,174 +48,106 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
_loadReactionSets();
}
Future<void> _loadReactionSets() async {
// Start with emoji set
final reactionSets = <String, List<String>>{
'emoji': [
'❤️', '👍', '😂', '😮', '😢', '😡',
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
'😍', '🤣', '😊', '👌', '🙌', '💪',
'🎯', '', '', '🌟', '💫', '☀️',
],
};
final folderCredits = <String, String>{};
final tabOrder = ['emoji']; // Start with emoji, will add folders after
// Known reaction folders to check
final knownFolders = ['dotto', 'green', 'blue', 'purple'];
for (final folder in knownFolders) {
try {
// Try to load credit file
final creditContent = await _loadCreditFile(folder);
folderCredits[folder] = creditContent;
final reactionSets = <String, List<String>>{
'emoji': [
'❤️', '👍', '😂', '😮', '😢', '😡',
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
'😍', '🤣', '😊', '👌', '🙌', '💪',
'🎯', '', '', '🌟', '💫', '☀️',
],
};
// Try to load files from this folder
final reactions = await _loadAllFilesFromFolder(folder);
final folderCredits = <String, String>{};
final tabOrder = ['emoji'];
// Load the manifest to discover assets
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final assetPaths = manifest.listAssets();
if (reactions.isNotEmpty) {
reactionSets[folder] = reactions;
tabOrder.add(folder);
// 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 {
final creditPath = 'assets/reactions/$folderName/credit.md';
// Check if credit file exists in manifest too
if (assetPaths.contains(creditPath)) {
final creditData = await rootBundle.loadString(creditPath);
folderCredits[folderName] = creditData;
}
} catch (e) {
// Ignore missing credit files
}
}
reactionSets[folderName]!.add(path);
}
}
// Sort reactions within each set by file name
for (final key in reactionSets.keys) {
if (key != 'emoji') {
reactionSets[key]!.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
}
}
if (mounted) {
setState(() {
_reactionSets = reactionSets;
_folderCredits = folderCredits;
_tabOrder = tabOrder;
_isLoading = false;
_tabController = TabController(length: _tabOrder.length, vsync: this);
_tabController.addListener(() {
if (mounted) {
setState(() {
_currentTabIndex = _tabController.index;
_clearSearch();
});
}
});
});
}
} catch (e) {
// Error loading folder, skip it
}
}
setState(() {
_reactionSets = reactionSets;
_folderCredits = folderCredits;
_tabOrder = tabOrder;
_isLoading = false;
// Create TabController after setting up the data
_tabController = TabController(length: _tabOrder.length, vsync: this);
_tabController.addListener(() {
setState(() {
_currentTabIndex = _tabController.index;
_clearSearch();
});
});
});
}
Future<List<String>> _loadAllFilesFromFolder(String folder) async {
final reactions = <String>[];
// Common file names to try (comprehensive list)
final possibleFiles = [
// Basic reactions
'heart', 'thumbs_up', 'laugh', 'lol', 'wow', 'sad', 'angry', 'mad',
'party', 'fire', 'clap', 'pray', 'hundred', 'thinking', 'ok',
// Face expressions
'smile', 'happy', 'grinning', 'beaming', 'wink', 'kiss', 'love',
'laughing', 'crying', 'tears', 'joy', 'giggle', 'chuckle',
'frown', 'worried', 'scared', 'fear', 'shock', 'surprised',
'confused', 'thinking_face', 'face_palm', 'eyeroll',
// Extended face names
'laughing_face', 'beaming_face', 'face_with_tears', 'grinning_face',
'smiling_face', 'winking_face', 'melting_face', 'upside_down_face',
'rolling_face', 'slightly_smiling_face', 'smiling_face_with_halo',
'smiling_face_with_hearts', 'face_with_monocle', 'nerd_face',
'party_face', 'sunglasses_face', 'disappointed_face', 'worried_face',
'anguished_face', 'fearful_face', 'downcast_face', 'loudly_crying_face',
// Special characters
'skull', 'ghost', 'robot', 'alien', 'monster', 'devil', 'angel',
'poop', 'vomit', 'sick', 'dizzy', 'sleeping', 'zzz',
// Hearts and love
'heart_with_arrow', 'broken_heart', 'sparkling_heart', 'two_hearts',
'revolving_hearts', 'heart_eyes', 'kissing_heart',
// Colored hearts
'green_heart', 'blue_heart', 'purple_heart', 'yellow_heart',
'black_heart', 'white_heart', 'brown_heart', 'orange_heart',
// Actions and objects
'thumbs_down', 'ok_hand', 'peace', 'victory', 'rock_on', 'call_me',
'point_up', 'point_down', 'point_left', 'point_right',
'raised_hand', 'wave', 'clap', 'high_five', 'pray', 'namaste',
// Nature and elements
'fire', 'water', 'earth', 'air', 'lightning', 'storm', 'rainbow',
'sun', 'moon', 'star', 'cloud', 'tree', 'flower', 'leaf',
// Food and drink
'pizza', 'burger', 'taco', 'ice_cream', 'coffee', 'tea', 'beer',
'wine', 'cocktail', 'cake', 'cookie', 'candy', 'chocolate',
// Animals
'dog', 'cat', 'mouse', 'rabbit', 'bear', 'lion', 'tiger', 'elephant',
'monkey', 'bird', 'fish', 'butterfly', 'spider', 'snake',
// Objects and symbols
'bomb', 'knife', 'gun', 'pistol', 'sword', 'shield', 'crown',
'gem', 'diamond', 'money', 'coin', 'dollar', 'gift', 'present',
// Numbers and symbols
'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten',
'zero', 'plus', 'minus', 'multiply', 'divide', 'equal', 'check', 'x',
// Common variations with underscores
'thumbs_up', 'thumbs_down', 'middle_finger', 'rock_on', 'peace_sign',
'ok_sign', 'victory_sign', 'call_me_hand', 'raised_hand', 'wave_hand',
// Emoji-style names
'face_with_open_mouth', 'face_with_closed_eyes', 'face_with_tears_of_joy',
'grinning_face_with_big_eyes', 'grinning_face_with_smiling_eyes',
'grinning_face_with_sweat', 'grinning_squinting_face', 'hugging_face',
'face_with_head_bandage', 'face_with_thermometer', 'face_with_bandage',
'nauseated_face', 'sneezing_face', 'yawning_face', 'face_with_cowboy_hat',
// More descriptive names
'party_popper', 'confetti_ball', 'balloon', 'ribbon', 'gift_ribbon',
'birthday_cake', 'wedding_cake', 'christmas_tree', 'pumpkin', 'ghost_halloween',
// Simple variations
'like', 'dislike', 'love', 'hate', 'yes', 'no', 'maybe', 'idk',
'cool', 'hot', 'cold', 'warm', 'fresh', 'old', 'new', 'classic',
// Tech and modern
'computer', 'phone', 'camera', 'video', 'music', 'game', 'controller',
'mouse', 'keyboard', 'screen', 'monitor', 'laptop', 'tablet',
// Expressions
'lol', 'lmao', 'rofl', 'omg', 'wtf', 'smh', 'idc', 'ngl', 'fr',
'tbh', 'iykyk', 'rn', 'asap', 'fyi', 'btw', 'imo', 'imho',
];
// Try both PNG and SVG extensions for each possible file name
for (final fileName in possibleFiles) {
for (final extension in ['png', 'svg']) {
final fullPath = 'reactions/$folder/$fileName.$extension';
try {
// Try to load the file to check if it exists
await rootBundle.load(fullPath);
final assetPath = 'assets/$fullPath';
reactions.add(assetPath);
break; // Found this file, don't try other extensions
} catch (e) {
// File doesn't exist, try next extension
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
void dispose() {
@ -447,9 +382,7 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
// Reaction grid
SizedBox(
height: _isSearching && _filteredReactions.isNotEmpty
? (_filteredReactions.length / 6).ceil() * 60
: 240, // Dynamic height based on search results
height: 420, // Increased height to show more rows at once
child: TabBarView(
controller: _tabController,
children: _tabOrder.map((tabName) {
@ -461,7 +394,7 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
children: [
// Reaction grid
Expanded(
child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, isEmoji),
child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, !isEmoji),
),
// Credit section (only for non-emoji tabs)
@ -500,71 +433,23 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
}
Widget _buildCreditDisplay(String credit) {
final lines = credit.split('\n');
final widgets = <Widget>[];
for (final line in lines) {
if (line.trim().isEmpty) continue;
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));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
return MarkdownBody(
data: credit,
selectable: true,
onTapLink: (text, href, title) {
if (href != null) {
launchUrl(Uri.parse(href));
}
},
styleSheet: MarkdownStyleSheet(
p: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
h1: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
h2: GoogleFonts.inter(fontSize: 11, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
listBullet: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
strong: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
em: GoogleFonts.inter(fontSize: 10, fontStyle: FontStyle.italic, color: AppTheme.textPrimary),
a: GoogleFonts.inter(fontSize: 10, color: AppTheme.brightNavy, decoration: TextDecoration.underline),
),
);
}
@ -575,8 +460,8 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
bool useImages,
) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(4),
physics: const BouncingScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
crossAxisSpacing: 8,
@ -594,7 +479,10 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
child: InkWell(
onTap: () {
Navigator.of(context).pop();
widget.onReactionSelected(reaction);
final result = reaction.startsWith('assets/')
? 'asset:$reaction'
: reaction;
widget.onReactionSelected(result);
},
borderRadius: BorderRadius.circular(12),
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')) {
return SvgPicture.asset(
imagePath,
@ -669,7 +561,6 @@ Future<List<String>> _loadAllFilesFromFolder(String folder) async {
),
),
errorBuilder: (context, error, stackTrace) {
// Fallback to emoji if image not found
return Icon(
Icons.image_not_supported,
size: 24,

View file

@ -4,8 +4,11 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../theme/app_theme.dart';
class ReactionStrip extends StatelessWidget {
final Map<String, int> reactions;
final Set<String> myReactions;
@ -194,8 +197,19 @@ class _ReactionIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (reactionId.startsWith('asset:')) {
final assetPath = reactionId.replaceFirst('asset:', '');
if (reactionId.startsWith('asset:') || reactionId.startsWith('assets/')) {
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(
assetPath,
width: 18,
@ -240,10 +254,8 @@ class _ReactionPickerSheetState extends State<_ReactionPickerSheet> {
Future<void> _loadAssetReactions() async {
try {
final manifest = await DefaultAssetBundle.of(context)
.loadString('AssetManifest.json');
final map = jsonDecode(manifest) as Map<String, dynamic>;
final keys = map.keys
final manifest = await AssetManifest.loadFromAssetBundle(DefaultAssetBundle.of(context));
final keys = manifest.listAssets()
.where((key) => key.startsWith('assets/reactions/'))
.toList()
..sort();

View file

@ -1,83 +1,256 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
/// Displays all reactions for a post
/// Used in thread/detail views to show comprehensive reaction breakdown
enum ReactionsDisplayMode {
/// 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 {
final Map<String, int> reactionCounts;
final Set<String> myReactions;
final VoidCallback? onReactionTap;
final bool showAll;
final Map<String, List<String>>? reactionUsers;
final Function(String)? onToggleReaction;
final VoidCallback? onAddReaction;
final ReactionsDisplayMode mode;
final EdgeInsets? padding;
const ReactionsDisplay({
super.key,
required this.reactionCounts,
required this.myReactions,
this.onReactionTap,
this.showAll = true,
this.reactionUsers,
this.onToggleReaction,
this.onAddReaction,
this.mode = ReactionsDisplayMode.full,
this.padding,
});
@override
Widget build(BuildContext context) {
if (reactionCounts.isEmpty) {
if (reactionCounts.isEmpty && onAddReaction == null) {
return const SizedBox.shrink();
}
List<MapEntry<String, int>> reactions;
if (showAll) {
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));
if (mode == ReactionsDisplayMode.compact) {
return _buildCompactView();
}
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(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: padding ?? const EdgeInsets.symmetric(vertical: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: reactions.map((entry) {
final emoji = entry.key;
final count = entry.value;
final isMyReaction = myReactions.contains(emoji);
return GestureDetector(
onTap: onReactionTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isMyReaction
? AppTheme.brightNavy.withValues(alpha: 0.15)
: AppTheme.navyBlue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: isMyReaction
? Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.3))
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
emoji,
style: const TextStyle(fontSize: 14),
),
const SizedBox(width: 4),
Text(
count > 99 ? '99+' : '$count',
style: GoogleFonts.inter(
color: isMyReaction ? AppTheme.brightNavy : AppTheme.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}).toList(),
crossAxisAlignment: WrapCrossAlignment.center,
children: [
...sortedEntries.map((entry) {
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!),
],
),
);
}
}
class _ReactionChip extends StatefulWidget {
final String reactionId;
final int count;
final bool isSelected;
final List<String>? tooltipNames;
final VoidCallback onTap;
const _ReactionChip({
required this.reactionId,
required this.count,
required this.isSelected,
required this.onTap,
this.tooltipNames,
});
@override
State<_ReactionChip> createState() => _ReactionChipState();
}
class _ReactionChipState extends State<_ReactionChip> {
int _tapCount = 0;
void _handleTap() {
HapticFeedback.selectionClick();
setState(() => _tapCount += 1);
widget.onTap();
}
@override
Widget build(BuildContext context) {
final isMyReaction = widget.isSelected;
final chip = GestureDetector(
onTap: _handleTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isMyReaction
? AppTheme.brightNavy.withValues(alpha: 0.15)
: AppTheme.navyBlue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: isMyReaction
? Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.3))
: null,
boxShadow: isMyReaction
? [
BoxShadow(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ReactionIcon(reactionId: widget.reactionId, size: 18),
if (widget.count > 0) ...[
const SizedBox(width: 4),
Text(
widget.count > 99 ? '99+' : '${widget.count}',
style: GoogleFonts.inter(
color: isMyReaction ? AppTheme.brightNavy : AppTheme.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
)
.animate(key: ValueKey('tap_$_tapCount'))
.scale(begin: const Offset(1, 1), end: const Offset(1.1, 1.1), duration: 100.ms, curve: Curves.easeOut)
.then()
.scale(begin: const Offset(1.1, 1.1), end: const Offset(1, 1), duration: 150.ms, curve: Curves.easeOutBack);
final names = widget.tooltipNames;
if (names == null || names.isEmpty) return chip;
return Tooltip(
message: names.take(5).join(', '),
child: chip,
);
}
}
class _ReactionAddButton extends StatelessWidget {
final VoidCallback onTap;
const _ReactionAddButton({required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.add_reaction_outlined,
color: AppTheme.textSecondary,
size: 20,
),
],
),
),
);
}
}
class _ReactionIcon extends StatelessWidget {
final String reactionId;
final double size;
const _ReactionIcon({required this.reactionId, this.size = 14});
@override
Widget build(BuildContext context) {
if (reactionId.startsWith('assets/') || reactionId.startsWith('asset:')) {
final assetPath = reactionId.startsWith('asset:')
? reactionId.replaceFirst('asset:', '')
: reactionId;
if (assetPath.endsWith('.svg')) {
return SvgPicture.asset(
assetPath,
width: size,
height: size,
);
}
return Image.asset(
assetPath,
width: size,
height: size,
fit: BoxFit.contain,
);
}
return Text(
reactionId,
style: TextStyle(fontSize: size),
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../theme/app_theme.dart';
class SmartReactionButton extends ConsumerWidget {
@ -15,6 +16,32 @@ class SmartReactionButton extends ConsumerWidget {
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
Widget build(BuildContext context, WidgetRef ref) {
// Determine what to show
@ -28,10 +55,7 @@ class SmartReactionButton extends ConsumerWidget {
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
myReaction,
style: const TextStyle(fontSize: 18),
),
_buildReactionContent(myReaction),
const SizedBox(width: 4),
Text(
totalCount > 99 ? '99+' : '$totalCount',
@ -61,10 +85,7 @@ class SmartReactionButton extends ConsumerWidget {
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
topReaction.key,
style: const TextStyle(fontSize: 18),
),
_buildReactionContent(topReaction.key),
const SizedBox(width: 4),
Text(
totalCount > 99 ? '99+' : '$totalCount',

View file

@ -36,11 +36,14 @@ class sojornAppBar extends StatelessWidget implements PreferredSizeWidget {
onPressed: onBackPressed ?? () => Navigator.of(context).pop(),
)
: null),
title: title != null
? Text(
title!,
title: (title == null || title!.isEmpty)
? Image.asset(
'assets/images/toplogo.png',
height: 44,
)
: null,
: Text(
title!,
),
actions: actions,
bottom: bottom,
);

View file

@ -193,6 +193,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -213,10 +221,10 @@ packages:
dependency: transitive
description:
name: cross_file
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+1"
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@ -305,6 +313,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -325,10 +349,10 @@ packages:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.5"
ffmpeg_kit_flutter_new:
dependency: "direct main"
description:
@ -829,6 +853,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.5"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router:
dependency: "direct main"
description:
@ -869,6 +901,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
html:
dependency: transitive
description:
@ -913,10 +953,10 @@ packages:
dependency: transitive
description:
name: image_picker_android
sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677"
sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
url: "https://pub.dev"
source: hosted
version: "0.8.13+10"
version: "0.8.13+13"
image_picker_for_web:
dependency: transitive
description:
@ -929,10 +969,10 @@ packages:
dependency: transitive
description:
name: image_picker_ios
sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad"
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+3"
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
@ -985,10 +1025,10 @@ packages:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
version: "4.10.0"
latlong2:
dependency: "direct main"
description:
@ -1025,10 +1065,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
version: "6.1.0"
lists:
dependency: transitive
description:
@ -1141,6 +1181,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -1149,6 +1197,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -1209,10 +1265,10 @@ packages:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
@ -1345,10 +1401,10 @@ packages:
dependency: "direct main"
description:
name: pro_video_editor
sha256: "1b3229558edaf2b1c75d771a83c5ac8897d63d0dc9845f95de7b8428d5d6fbdf"
sha256: "18f62235212ff779a2ca967df4ce06cac22b7ff45051f46519754d94db2b04ff"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.1"
proj4dart:
dependency: transitive
description:
@ -1365,6 +1421,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -1473,10 +1537,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
url: "https://pub.dev"
source: hosted
version: "2.4.18"
version: "2.4.20"
shared_preferences_foundation:
dependency: transitive
description:
@ -1734,10 +1798,10 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
@ -1830,10 +1894,10 @@ packages:
dependency: transitive
description:
name: video_player_avfoundation
sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
sha256: "7cc0a9257103851eb299a2407e895b0fd6832d323dcfde622a23cdc25a1de269"
url: "https://pub.dev"
source: hosted
version: "2.8.9"
version: "2.9.0"
video_player_platform_interface:
dependency: transitive
description:
@ -1963,5 +2027,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"

View file

@ -1,5 +1,5 @@
name: sojorn
description: "sojorn - Friend's Only"
description: "Sojorn - Friend's Only. A product of MPLS LLC."
publish_to: 'none'
version: 1.0.0+1
@ -20,6 +20,7 @@ dependencies:
# HTTP & API
http: ^1.2.2
dio: ^5.7.0
# UI & Utilities
cupertino_icons: ^1.0.8
@ -96,6 +97,13 @@ flutter:
- assets/images/applogo.png
- assets/images/toplogo.png
- assets/reactions/
- assets/reactions/dotto/
- assets/reactions/blue/
- assets/reactions/green/
- assets/reactions/purple/
- assets/icon/
- assets/rive/
- assets/audio/
dependency_overrides:
intl: 0.19.0

View file

@ -18,7 +18,7 @@
<meta charset="UTF-8">
<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">
<!-- iOS meta tags & icons -->

View file

@ -5,7 +5,7 @@
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"description": "Sojorn - Friend's Only. A product of MPLS LLC.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
@ -32,4 +32,4 @@
"purpose": "maskable"
}
]
}
}

View file

@ -142,6 +142,7 @@ encrypted_messages (ciphertext + header + metadata)
- ✅ OTK management (generation, usage, deletion)
- ✅ Backend key storage/retrieval
- ✅ Cross-platform encryption (Android↔Web)
- ✅ **Full Backup & Recovery** (Keys + Messages)
### Key Files Modified
```
@ -188,63 +189,56 @@ Receiver:
[DECRYPT] SUCCESS: Decrypted message: "[message_text]"
```
## Next Steps: Message Recovery
## Backup & Recovery System ✅
### Problem
When users uninstall the app or lose local keys, they cannot decrypt historical messages.
### Overview
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
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
### Architecture
### 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)
- Encrypt identity keys with user password
- Store encrypted backup in cloud storage
- Recover keys with password authentication
#### 2. Backup Content
The encrypted backup file contains two main components:
1. **Key Material**:
* 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**:
- Most user-friendly
- Maintains security (password-protected)
- Technically straightforward
- Reversible if needed
#### 3. Backup Flow
1. **User Initiation**: User selects "Full Backup & Recovery" in settings.
2. **Password Entry**: User sets a strong backup password.
3. **Data Gathering**:
* `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
- Allow trusted contacts to help recover keys
- Use Shamir's Secret Sharing for security
- Requires multiple trusted contacts
#### 4. Restore Flow
1. **File Selection**: User selects the `.json` backup file.
2. **Decryption**:
* 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)
- Store encrypted key backups on server
- Server cannot decrypt without user password
- Similar to Signal's approach
#### 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
### Technical Implementation
* **Service**: `LocalKeyBackupService` handles the encryption/decryption pipeline.
* **Store**: `LocalMessageStore` provides bulk export/import methods (`getAllMessageRecords`, `saveMessageRecord`).
* **UI**: `LocalBackupScreen` provides the interface for creating and restoring backups.
## Security Considerations
@ -287,10 +281,10 @@ The E2EE implementation is now fully functional with all major issues resolved.
- Automatic key management
- 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
**Status**: ✅ Production Ready (except key recovery)
**Next Priority**: Implement key recovery system
**Last Updated**: February 2, 2026
**Status**: ✅ Production Ready (including key/message recovery)
**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).