feat: Initialize Sojorn Flutter application with core UI, services, E2EE, backup, and build scripts for various platforms.
This commit is contained in:
parent
78f43494a2
commit
10ae2944d2
39
cloud_backup_status.md
Normal file
39
cloud_backup_status.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Cloud Backup Implementation Plan (Complete)
|
||||
|
||||
## 1. Frontend Implementation (Flutter)
|
||||
- [x] **ApiService Updates**: Added `uploadBackup` and `downloadBackup` methods to interact with the backend (endpoints `/backups/upload` and `/backups/download`).
|
||||
- [x] **LocalKeyBackupService Refactor**:
|
||||
- [x] Updated `createEncryptedBackup` to accept `includeKeys` and `includeMessages` flags.
|
||||
- [x] Added `uploadToCloud` method which **defaults to Messages Only** (no keys) for security.
|
||||
- [x] Added `restoreFromCloud` method to fetch and decrypt backups.
|
||||
- [x] **UI Overhaul (LocalBackupScreen)**:
|
||||
- [x] Added "Cloud Mode" vs "Local Mode" toggle.
|
||||
- [x] Implemented "Zero Knowledge" warning UI when Cloud Mode is active (keys excluded by default).
|
||||
- [x] Added visual cues for "Secure Mode".
|
||||
- [x] Integrated `uploadToCloud` and `restoreFromCloud` calls with progress indicators and error handling.
|
||||
|
||||
## 2. Backend Implementation (Go)
|
||||
- [x] **Database Schema**: Created migration `000003_e2ee_backup_recovery.up.sql` for:
|
||||
- `cloud_backups` table (stores encrypted blobs).
|
||||
- `backup_preferences` table.
|
||||
- `user_devices` table.
|
||||
- `sync_codes` table.
|
||||
- `recovery_guardians` and `recovery_sessions` tables (for future social recovery).
|
||||
- [x] **API Endpoints**:
|
||||
- `POST /backups/upload`: Accepts encrypted blob, metadata, and version.
|
||||
- `GET /backups/download`: Retrieves latest backup.
|
||||
- `GET /backups/download/:backup_id`: Retrieves specific backup.
|
||||
- [x] **Data Models**: Defined `CloudBackup`, `UploadBackupRequest`, `DownloadBackupResponse` structs matching frontend expectations.
|
||||
- [x] **Handler Logic**: Implemented "blind storage" logic - backend stores opaque blobs and does not attempt decryption.
|
||||
|
||||
## 3. Deployment Status (Pending)
|
||||
- [x] **Compilation**: Successfully compiled `sojorn-api-linux` and `migrate-linux` binaries locally.
|
||||
- [ ] **Upload**: Failed to upload binaries to VPS (`194.238.28.122`) due to SSH authentication failure ("Permission denied") with provided credentials.
|
||||
- [ ] **Migration**: Database migration failed from local machine due to port 5432 being closed/filtered. Needs to be run from the VPS.
|
||||
- [ ] **Restart**: Service restart pending successful SSH access.
|
||||
|
||||
## 4. Next Steps
|
||||
Once SSH access is restored (verify password or add public key):
|
||||
1. **Upload Binaries**: `scp sojorn-api-linux migrate-linux root@194.238.28.122:/root/`
|
||||
2. **Run Migration**: `ssh root@... "./migrate-linux -path ... up"`
|
||||
3. **Restart Service**: `ssh root@... "systemctl restart sojorn-api"`
|
||||
35
feed_reactions_fix.md
Normal file
35
feed_reactions_fix.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Feed Reactions Fix - Implementation Summary
|
||||
|
||||
## Status: COMPLETE & DEPLOYED ✅
|
||||
|
||||
### 1. Issue Description
|
||||
- **Problem**: Reactions were not displaying on the "Following" (Home) and "Profile" feeds. The "Postcard" UI (specifically the compact method) was defaulting to the "Add Reaction" button because no reaction data was being returned by the API for these lists.
|
||||
- **Root Cause**: The SQL queries for `GetFeed` and `GetPostsByAuthor` in the backend were not aggregating reaction data (counts and user choices), unlike the single-post endpoint.
|
||||
|
||||
### 2. Implementation Details (Backend)
|
||||
- **File**: `internal/repository/post_repository.go`
|
||||
- **Changes**:
|
||||
- Modified `GetFeed` SQL query to include a correlated subquery fetching `jsonb_object_agg(emoji, count)` for `reaction_counts`.
|
||||
- Modified `GetPostsByAuthor` SQL query to do the same.
|
||||
- Added logic to fetch `my_reactions` (the current user's votes) for both feeds.
|
||||
- Updated the Go `Scan` destinations to populate the `Reactions` and `MyReactions` fields in the `Post` model.
|
||||
- **Correction**: Also fixed a missing `allow_chain` and `visibility` selection in the `GetPostsByAuthor` query, ensuring consistency across the app.
|
||||
|
||||
### 3. Frontend logic (Verified)
|
||||
- **Widget**: `sojornPostCard` -> `PostActions` -> `ReactionsDisplay`
|
||||
- **Logic**: The `ReactionsDisplay` widget in `compact` mode (used in feeds) is designed to:
|
||||
1. Show the user's reaction if they voted.
|
||||
2. Else, show the "Most Used" reaction from the community.
|
||||
3. Else (if valid data but count is 0), show the "Add" button.
|
||||
- **Result**: Now that the backend provides the data (Case 1 or 2), the UI will correctly display the reaction chips instead of just the "+" button.
|
||||
|
||||
### 4. Deployment
|
||||
- **Server**: `194.238.28.122`
|
||||
- **Action**:
|
||||
- Compiled `sojorn-api-linux` locally.
|
||||
- Uploaded to `/opt/sojorn/bin/api` via `scp` (user: `patrick`).
|
||||
- Restarted `sojorn-api` service via `systemctl`.
|
||||
- **Status**: API is active and serving the new queries.
|
||||
|
||||
### 5. Next Steps
|
||||
- **User Action**: Pull-to-refresh the feed in the Sojorn app to see the changes.
|
||||
|
|
@ -26,7 +26,7 @@ Get-Content $EnvPath | ForEach-Object {
|
|||
$values[$key] = $value
|
||||
}
|
||||
|
||||
$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'])"
|
||||
)
|
||||
|
||||
|
|
|
|||
16
run_web.ps1
16
run_web.ps1
|
|
@ -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
86
run_web_chrome.ps1
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
param(
|
||||
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
||||
[int]$Port = 8002,
|
||||
[string]$Renderer = "auto", # Options: auto, canvaskit, html
|
||||
[switch]$NoWasmDryRun
|
||||
)
|
||||
|
||||
function Parse-Env($path) {
|
||||
$vals = @{}
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||
# Set default API_BASE_URL since no .env exists
|
||||
$vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1'
|
||||
return $vals
|
||||
}
|
||||
Get-Content $path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { return }
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -lt 2) { return }
|
||||
$key = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$vals[$key] = $value
|
||||
}
|
||||
return $vals
|
||||
}
|
||||
|
||||
$values = Parse-Env $EnvPath
|
||||
|
||||
# Collect dart-defines we actually use on web.
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure API_BASE_URL is set
|
||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||
$currentApi = 'https://api.gosojorn.com/api/v1'
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
} else {
|
||||
$currentApi = $values['API_BASE_URL']
|
||||
# Always ensure we're using the HTTPS endpoint
|
||||
if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) {
|
||||
$currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com')
|
||||
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
} elseif ($currentApi.StartsWith('http://localhost:')) {
|
||||
# For local development, keep localhost but warn
|
||||
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Launching Sojorn Web (Chrome)..." -ForegroundColor Cyan
|
||||
Write-Host "Port: $Port"
|
||||
Write-Host "Renderer: $Renderer"
|
||||
Write-Host "API: $currentApi"
|
||||
|
||||
Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
||||
try {
|
||||
$cmdArgs = @(
|
||||
'run',
|
||||
'-d',
|
||||
'chrome',
|
||||
'--web-hostname',
|
||||
'localhost',
|
||||
'--web-port',
|
||||
"$Port"
|
||||
)
|
||||
$cmdArgs += $defineArgs
|
||||
|
||||
if ($NoWasmDryRun) {
|
||||
$cmdArgs += '--no-wasm-dry-run'
|
||||
}
|
||||
|
||||
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||
& flutter $cmdArgs
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
111
run_windows.ps1
Normal file
111
run_windows.ps1
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
param(
|
||||
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
||||
[switch]$Release,
|
||||
[switch]$Clean
|
||||
)
|
||||
|
||||
function Parse-Env($path) {
|
||||
$vals = @{}
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||
# Set default API_BASE_URL since no .env exists
|
||||
$vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1'
|
||||
return $vals
|
||||
}
|
||||
Get-Content $path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { return }
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -lt 2) { return }
|
||||
$key = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$vals[$key] = $value
|
||||
}
|
||||
return $vals
|
||||
}
|
||||
|
||||
$values = Parse-Env $EnvPath
|
||||
|
||||
# Collect dart-defines we actually use on Windows.
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure API_BASE_URL is set
|
||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||
$currentApi = 'https://api.gosojorn.com/api/v1'
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
} else {
|
||||
$currentApi = $values['API_BASE_URL']
|
||||
if ($currentApi.StartsWith('http://localhost:')) {
|
||||
# For local development, keep localhost but warn
|
||||
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Building Sojorn for Windows..." -ForegroundColor Cyan
|
||||
Write-Host "API: $currentApi"
|
||||
Write-Host "Mode: $(if ($Release) { 'Release' } else { 'Debug' })"
|
||||
|
||||
Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
||||
try {
|
||||
# Clean build if requested
|
||||
if ($Clean) {
|
||||
Write-Host "Cleaning build cache..." -ForegroundColor Yellow
|
||||
& flutter clean
|
||||
& flutter pub get
|
||||
}
|
||||
|
||||
# Build for Windows
|
||||
$cmdArgs = @(
|
||||
'build',
|
||||
'windows'
|
||||
)
|
||||
|
||||
if ($Release) {
|
||||
$cmdArgs += '--release'
|
||||
} else {
|
||||
$cmdArgs += '--debug'
|
||||
}
|
||||
|
||||
$cmdArgs += $defineArgs
|
||||
|
||||
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||
& flutter $cmdArgs
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$buildPath = Join-Path (Get-Location) "build\windows\x64\runner\Release"
|
||||
if (-not $Release) {
|
||||
$buildPath = Join-Path (Get-Location) "build\windows\x64\runner\Debug"
|
||||
}
|
||||
|
||||
$exePath = Join-Path $buildPath "sojorn_app.exe"
|
||||
|
||||
if (Test-Path $exePath) {
|
||||
Write-Host "Build successful!" -ForegroundColor Green
|
||||
Write-Host "Executable: $exePath" -ForegroundColor Cyan
|
||||
Write-Host "Build folder: $buildPath" -ForegroundColor Cyan
|
||||
|
||||
# Ask if user wants to run the app
|
||||
$response = Read-Host "Do you want to run the app now? (y/n)"
|
||||
if ($response -eq 'y' -or $response -eq 'Y') {
|
||||
Write-Host "Starting Sojorn Windows app..." -ForegroundColor Yellow
|
||||
Start-Process -FilePath $exePath
|
||||
}
|
||||
} else {
|
||||
Write-Host "Build completed but executable not found at: $exePath" -ForegroundColor Red
|
||||
}
|
||||
} else {
|
||||
Write-Host "Build failed!" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
34
run_windows_app.ps1
Normal file
34
run_windows_app.ps1
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
param(
|
||||
[string]$BuildType = "Release", # Release or Debug
|
||||
[switch]$BuildFirst
|
||||
)
|
||||
|
||||
$buildPath = Join-Path $PSScriptRoot "sojorn_app\build\windows\x64\runner\$BuildType"
|
||||
$exePath = Join-Path $buildPath "sojorn_app.exe"
|
||||
|
||||
Write-Host "Sojorn Windows App Runner" -ForegroundColor Cyan
|
||||
Write-Host "Build Type: $BuildType" -ForegroundColor White
|
||||
Write-Host "Executable: $exePath" -ForegroundColor White
|
||||
|
||||
# Build first if requested
|
||||
if ($BuildFirst) {
|
||||
Write-Host "Building first..." -ForegroundColor Yellow
|
||||
& "$PSScriptRoot\run_windows.ps1" -Release:($BuildType -eq "Release")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Build failed, cannot run app." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Check if executable exists
|
||||
if (-not (Test-Path $exePath)) {
|
||||
Write-Host "Executable not found: $exePath" -ForegroundColor Red
|
||||
Write-Host "Try building first with: .\run_windows.ps1 -Release" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run the app
|
||||
Write-Host "Starting Sojorn Windows app..." -ForegroundColor Green
|
||||
Start-Process -FilePath $exePath -WorkingDirectory $buildPath
|
||||
|
||||
Write-Host "App launched! Check your taskbar or desktop for the Sojorn window." -ForegroundColor Cyan
|
||||
|
|
@ -1,11 +1,3 @@
|
|||
# Dotto Emoji Set
|
||||
|
||||
**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
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Download Dotto Emoji SVG files from GitHub repository
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Mapping of emoji names to GitHub SVG files
|
||||
EMOJI_MAPPING = {
|
||||
'heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji0.svg',
|
||||
'thumbs_up.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji1.svg',
|
||||
'laughing_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/face_with_tears_of_joy.svg',
|
||||
'face_with_open_mouth.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji2.svg',
|
||||
'sad_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji3.svg',
|
||||
'angry_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji4.svg',
|
||||
'party_popper.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji5.svg',
|
||||
'fire.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji6.svg',
|
||||
'clapping_hands.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji7.svg',
|
||||
'folded_hands.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji8.svg',
|
||||
'hundred_points.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji9.svg',
|
||||
'thinking_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji10.svg',
|
||||
'beaming_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/beaming_face_with_smiling_eyes.svg',
|
||||
'face_with_tears.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/face_with_tears_of_joy.svg',
|
||||
'grinning_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/grinning_face.svg',
|
||||
'smiling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_smiling_eyes.svg',
|
||||
'winking_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/winking_face.svg',
|
||||
'melting_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/melting_face.svg',
|
||||
'upside_down_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/upside-down_face.svg',
|
||||
'rolling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/rolling_on_the_floor_laughing.svg',
|
||||
'slightly_smiling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/slightly_smiling_face.svg',
|
||||
'smiling_face_with_halo.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_halo.svg',
|
||||
'smiling_face_with_hearts.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_hearts.svg',
|
||||
'face_with_monocle.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji65.svg',
|
||||
'nerd_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji66.svg',
|
||||
'party_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji67.svg',
|
||||
'sunglasses_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji68.svg',
|
||||
'disappointed_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji69.svg',
|
||||
'worried_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji70.svg',
|
||||
'anguished_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji71.svg',
|
||||
'fearful_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji72.svg',
|
||||
'downcast_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji73.svg',
|
||||
'loudly_crying_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji74.svg',
|
||||
'skull.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji75.svg',
|
||||
'ghost.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji76.svg',
|
||||
'robot_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji77.svg',
|
||||
'heart_with_arrow.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji78.svg',
|
||||
'broken_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji79.svg',
|
||||
'sparkling_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji80.svg',
|
||||
'green_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji81.svg',
|
||||
'blue_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji82.svg',
|
||||
'purple_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji83.svg',
|
||||
}
|
||||
|
||||
def download_file(url, local_path):
|
||||
"""Download a file from URL to local path"""
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
with open(local_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
print(f"✓ Downloaded {os.path.basename(local_path)}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to download {os.path.basename(local_path)}: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main function to download all emoji files"""
|
||||
print("Downloading Dotto Emoji SVG files...")
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs('.', exist_ok=True)
|
||||
|
||||
success_count = 0
|
||||
total_count = len(EMOJI_MAPPING)
|
||||
|
||||
for filename, url in EMOJI_MAPPING.items():
|
||||
local_path = filename
|
||||
if download_file(url, local_path):
|
||||
success_count += 1
|
||||
|
||||
print(f"\nDownload complete: {success_count}/{total_count} files downloaded")
|
||||
|
||||
if success_count > 0:
|
||||
print("\nThe Dotto emoji set is now ready to use!")
|
||||
print("You can now run 'flutter pub get' to install dependencies.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -330,12 +330,16 @@ class PostPreview {
|
|||
final String body;
|
||||
final 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
395
sojorn_docs/SOJORN_ARCHITECTURE.md
Normal file
395
sojorn_docs/SOJORN_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
# Sojorn Architecture & Deployment Guide
|
||||
|
||||
> **Last Updated:** 2026-02-03
|
||||
> **Maintainer:** MPLS LLC
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Overview](#overview)
|
||||
2. [Project Structure](#project-structure)
|
||||
3. [Flutter App](#flutter-app)
|
||||
4. [Go Backend](#go-backend)
|
||||
5. [Server Deployment](#server-deployment)
|
||||
6. [Database](#database)
|
||||
7. [Common Commands](#common-commands)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Sojorn** is a social media platform with the following core features:
|
||||
- **Posts & Feeds:** Category-based content discovery
|
||||
- **Chains (Threads):** Reddit-style threaded conversations
|
||||
- **Beacons:** Location-based ephemeral posts
|
||||
- **E2EE Chat:** End-to-end encrypted direct messaging
|
||||
- **Profiles:** User profiles with follow/friend system
|
||||
- **Reactions:** Custom emoji reactions on posts
|
||||
|
||||
### Tech Stack
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Mobile/Web App | Flutter (Dart) |
|
||||
| Backend API | Go (Gin framework) |
|
||||
| Database | PostgreSQL |
|
||||
| Media Storage | Cloudflare R2 |
|
||||
| Realtime | WebSockets |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
C:\Webs\Sojorn\
|
||||
├── sojorn_app/ # Flutter application
|
||||
│ ├── lib/ # Dart source code
|
||||
│ │ ├── screens/ # UI screens
|
||||
│ │ ├── widgets/ # Reusable widgets
|
||||
│ │ ├── services/ # Business logic & API calls
|
||||
│ │ ├── models/ # Data models
|
||||
│ │ ├── providers/ # Riverpod state management
|
||||
│ │ └── theme/ # App theming
|
||||
│ ├── web/ # Web-specific assets
|
||||
│ ├── android/ # Android configs
|
||||
│ ├── ios/ # iOS configs
|
||||
│ └── pubspec.yaml # Dependencies
|
||||
│
|
||||
├── go-backend/ # Go API server
|
||||
│ ├── cmd/api/ # Main entry point (main.go)
|
||||
│ ├── internal/
|
||||
│ │ ├── handlers/ # HTTP route handlers
|
||||
│ │ ├── repository/ # Database queries
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ ├── models/ # Go structs
|
||||
│ │ ├── middleware/ # Auth, CORS, rate limiting
|
||||
│ │ └── config/ # Environment configuration
|
||||
│ └── pkg/utils/ # Shared utilities
|
||||
│
|
||||
└── sojorn_docs/ # Documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flutter App
|
||||
|
||||
### Running Locally
|
||||
|
||||
```powershell
|
||||
cd C:\Webs\Sojorn\sojorn_app
|
||||
|
||||
# Web (Chrome)
|
||||
flutter run -d chrome
|
||||
|
||||
# Android
|
||||
flutter run -d <device-id>
|
||||
|
||||
# iOS
|
||||
flutter run -d <simulator-id>
|
||||
```
|
||||
|
||||
### API Configuration
|
||||
The app connects to the production API at:
|
||||
```
|
||||
https://api.gosojorn.com (or http://194.238.28.122:8080)
|
||||
```
|
||||
|
||||
Configuration is in: `lib/config/api_config.dart`
|
||||
|
||||
### Key Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/api_service.dart` | All backend API calls |
|
||||
| `lib/services/auth_service.dart` | Authentication & token management |
|
||||
| `lib/services/secure_chat_service.dart` | E2EE messaging |
|
||||
| `lib/screens/profile/profile_screen.dart` | User's own profile |
|
||||
| `lib/screens/profile/viewable_profile_screen.dart` | Viewing other profiles |
|
||||
|
||||
---
|
||||
|
||||
## Go Backend
|
||||
|
||||
### Local Development
|
||||
|
||||
```powershell
|
||||
cd C:\Webs\Sojorn\go-backend
|
||||
|
||||
# Run locally
|
||||
go run ./cmd/api
|
||||
|
||||
# Build binary
|
||||
go build -o sojorn-api ./cmd/api
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
Create `.env` file or set environment variables:
|
||||
```env
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/sojorn?sslmode=disable
|
||||
PORT=8080
|
||||
JWT_SECRET=your-secret
|
||||
CORS_ORIGINS=*
|
||||
R2_ACCESS_KEY=...
|
||||
R2_SECRET_KEY=...
|
||||
```
|
||||
|
||||
### Key Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `cmd/api/main.go` | Server setup & route registration |
|
||||
| `internal/handlers/post_handler.go` | Post CRUD & feed endpoints |
|
||||
| `internal/handlers/user_handler.go` | Profile & social endpoints |
|
||||
| `internal/repository/post_repository.go` | Post database queries |
|
||||
| `internal/repository/user_repository.go` | User database queries |
|
||||
|
||||
---
|
||||
|
||||
## Server Deployment
|
||||
|
||||
### Server Details
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **IP Address** | `194.238.28.122` |
|
||||
| **SSH User** | `patrick` |
|
||||
| **SSH Key** | `C:\Users\Patrick\.ssh\mpls.pem` |
|
||||
| **Sudo Password** | `P22k154ever!` |
|
||||
|
||||
### Directory Structure on Server
|
||||
```
|
||||
/opt/sojorn/
|
||||
├── bin/
|
||||
│ └── api # ⚠️ THE RUNNING BINARY
|
||||
├── go-backend/ # Git repo clone
|
||||
│ └── ...
|
||||
├── .env # Environment variables
|
||||
└── sojorn-api # Build output (copy to bin/api)
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
**Service Name:** `sojorn-api`
|
||||
**Config File:** `/etc/systemd/system/sojorn-api.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Sojorn Golang API Server
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/sojorn
|
||||
ExecStart=/opt/sojorn/bin/api # ⚠️ IMPORTANT: Uses bin/api
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
EnvironmentFile=/opt/sojorn/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Deployment Workflow
|
||||
|
||||
```bash
|
||||
# 1. SSH to server
|
||||
ssh -i "C:\Users\Patrick\.ssh\mpls.pem" patrick@194.238.28.122
|
||||
|
||||
# 2. Pull latest code
|
||||
cd /opt/sojorn/go-backend
|
||||
git fetch origin
|
||||
git reset --hard origin/ThreadRestoration # or main branch
|
||||
|
||||
# 3. Build
|
||||
go build -o sojorn-api ./cmd/api
|
||||
|
||||
# 4. Stop service, copy binary, start service
|
||||
sudo systemctl stop sojorn-api
|
||||
sudo cp sojorn-api /opt/sojorn/bin/api
|
||||
sudo systemctl start sojorn-api
|
||||
|
||||
# 5. Verify
|
||||
sudo systemctl status sojorn-api
|
||||
```
|
||||
|
||||
### Quick Deploy Command (from Windows)
|
||||
```powershell
|
||||
ssh -i "C:\Users\Patrick\.ssh\mpls.pem" patrick@194.238.28.122 "cd /opt/sojorn/go-backend && git fetch origin && git reset --hard origin/ThreadRestoration && go build -o sojorn-api ./cmd/api && echo 'P22k154ever!' | sudo -S systemctl stop sojorn-api && echo 'P22k154ever!' | sudo -S cp sojorn-api /opt/sojorn/bin/api && echo 'P22k154ever!' | sudo -S systemctl start sojorn-api"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Connection Details
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Host** | `localhost` (from server) |
|
||||
| **Port** | `5432` |
|
||||
| **Database** | `sojorn` |
|
||||
| **User** | `postgres` |
|
||||
| **Password** | `A24Zr7AEoch4eO0N` |
|
||||
|
||||
### Connection String
|
||||
```
|
||||
postgres://postgres:A24Zr7AEoch4eO0N@localhost:5432/sojorn?sslmode=disable
|
||||
```
|
||||
|
||||
### Key Tables
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `profiles` | User profiles |
|
||||
| `posts` | All posts (regular, chains, beacons) |
|
||||
| `post_metrics` | Like/comment counts |
|
||||
| `post_likes` | Who liked which post |
|
||||
| `post_saves` | Saved/bookmarked posts |
|
||||
| `post_reactions` | Emoji reactions |
|
||||
| `follows` | Follow relationships |
|
||||
| `conversations` | E2EE chat conversations |
|
||||
| `messages` | Chat messages |
|
||||
| `categories` | Content categories |
|
||||
| `category_settings` | User category preferences |
|
||||
|
||||
### Useful Queries
|
||||
```sql
|
||||
-- Check saved posts
|
||||
SELECT COUNT(*) FROM post_saves;
|
||||
|
||||
-- Check user's saved posts
|
||||
SELECT * FROM post_saves WHERE user_id = 'uuid-here';
|
||||
|
||||
-- Check posts table
|
||||
SELECT id, author_id, body, created_at FROM posts LIMIT 10;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Flutter App
|
||||
```powershell
|
||||
# Run on Chrome
|
||||
flutter run -d chrome
|
||||
|
||||
# Build web
|
||||
flutter build web
|
||||
|
||||
# Run tests
|
||||
flutter test
|
||||
|
||||
# Analyze code
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
### Go Backend
|
||||
```powershell
|
||||
# Run locally
|
||||
cd C:\Webs\Sojorn\go-backend
|
||||
go run ./cmd/api
|
||||
|
||||
# Build
|
||||
go build -o sojorn-api ./cmd/api
|
||||
|
||||
# Format code
|
||||
go fmt ./...
|
||||
```
|
||||
|
||||
### Server Management
|
||||
```bash
|
||||
# SSH to server
|
||||
ssh -i "C:\Users\Patrick\.ssh\mpls.pem" patrick@194.238.28.122
|
||||
|
||||
# Service commands
|
||||
sudo systemctl status sojorn-api
|
||||
sudo systemctl restart sojorn-api
|
||||
sudo systemctl stop sojorn-api
|
||||
sudo systemctl start sojorn-api
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u sojorn-api -f
|
||||
|
||||
# Database access
|
||||
PGPASSWORD=A24Zr7AEoch4eO0N psql -h localhost -U postgres -d sojorn
|
||||
```
|
||||
|
||||
### Git Operations
|
||||
```powershell
|
||||
# Commit and push (from local)
|
||||
cd C:\Webs\Sojorn\go-backend
|
||||
git add .
|
||||
git commit -m "Your message"
|
||||
git push
|
||||
|
||||
# Pull on server
|
||||
ssh ... "cd /opt/sojorn/go-backend && git pull"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Route Not Found (404) After Deployment
|
||||
**Cause:** Old binary is still running.
|
||||
**Solution:** Stop service before copying new binary:
|
||||
```bash
|
||||
sudo systemctl stop sojorn-api
|
||||
sudo cp sojorn-api /opt/sojorn/bin/api
|
||||
sudo systemctl start sojorn-api
|
||||
```
|
||||
|
||||
### "Text file busy" Error
|
||||
**Cause:** Trying to overwrite running binary.
|
||||
**Solution:** Stop the service first.
|
||||
|
||||
### Server Not Responding
|
||||
1. Check service status: `sudo systemctl status sojorn-api`
|
||||
2. Check logs: `sudo journalctl -u sojorn-api -n 50`
|
||||
3. Verify port: `sudo netstat -tlnp | grep 8080`
|
||||
|
||||
### Database Connection Errors
|
||||
1. Check PostgreSQL: `sudo systemctl status postgresql`
|
||||
2. Verify credentials in `/opt/sojorn/.env`
|
||||
3. Test connection: `psql -h localhost -U postgres -d sojorn`
|
||||
|
||||
### Flutter Build Errors
|
||||
1. Clean build: `flutter clean && flutter pub get`
|
||||
2. Analyze: `flutter analyze`
|
||||
3. Check Dart version: `flutter doctor`
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints Reference
|
||||
|
||||
### Authentication
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/auth/register` | Register new user |
|
||||
| POST | `/api/v1/auth/login` | Login |
|
||||
| POST | `/api/v1/auth/refresh` | Refresh token |
|
||||
|
||||
### Profile
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/profile` | Get own profile |
|
||||
| GET | `/api/v1/profiles/:id` | Get profile by ID |
|
||||
| PATCH | `/api/v1/profile` | Update profile |
|
||||
|
||||
### Posts
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/posts` | Create post |
|
||||
| GET | `/api/v1/posts/:id` | Get single post |
|
||||
| GET | `/api/v1/feed` | Get user's feed |
|
||||
| GET | `/api/v1/users/:id/posts` | Get user's posts |
|
||||
| GET | `/api/v1/users/:id/saved` | Get user's saved posts |
|
||||
| POST | `/api/v1/posts/:id/save` | Save a post |
|
||||
| DELETE | `/api/v1/posts/:id/save` | Unsave a post |
|
||||
|
||||
### Social
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/users/:id/follow` | Follow user |
|
||||
| DELETE | `/api/v1/users/:id/follow` | Unfollow user |
|
||||
| POST | `/api/v1/posts/:id/like` | Like post |
|
||||
| DELETE | `/api/v1/posts/:id/like` | Unlike post |
|
||||
|
||||
---
|
||||
|
||||
*This document should be updated whenever deployment procedures or architecture changes.*
|
||||
36
zero_knowledge_backup_summary.md
Normal file
36
zero_knowledge_backup_summary.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Zero Knowledge Cloud Backup - Implementation Summary
|
||||
|
||||
## Status: COMPLETE & DEPLOYED ✅
|
||||
|
||||
### 1. Security Architecture (Zero Knowledge)
|
||||
- **Principle**: The server never sees your private keys.
|
||||
- **Cloud Backups**: Default to **Messages Only**. The payload contains encrypted message history but explicitly excludes the key pairs needed to decrypt them.
|
||||
- **Local Backups**: Must be used to backup **Keys + Messages**. This is the only way to restore your identity (Subjective Identity).
|
||||
- **Encryption**: All data is encrypted client-side using Argon2id (password derivation) and AES-GCM (content encryption) before leaving the device.
|
||||
|
||||
### 2. Frontend Implementation (Flutter)
|
||||
- **Service**: Rebuilt `LocalKeyBackupService.dart` to handle the dual-mode backup logic.
|
||||
- **UI**:
|
||||
- Added toggle for "Cloud" vs "Local" backup.
|
||||
- "Secure Mode" indicator when uploading to cloud (confirming keys are excluded).
|
||||
- Explicit warning: "NOT RECOMMENDED for cloud backups. Keep keys local."
|
||||
|
||||
### 3. Backend Implementation (Go)
|
||||
- **Endpoints**: `POST /backups/upload` and `GET /backups/download` are active.
|
||||
- **Storage**: Stores opaque `encrypted_blob`, `salt`, `nonce`, and `mac`.
|
||||
- **Database**:
|
||||
- Applied migration `000003_e2ee_backup_recovery`.
|
||||
- Created tables: `cloud_backups`, `backup_preferences`, `user_devices`.
|
||||
|
||||
### 4. Deployment Details
|
||||
- **Server**: `194.238.28.122`
|
||||
- **Service**: `sojorn-api` (Restarted at ~01:55 server time)
|
||||
- **Database**: Migrations applied successfully via `migrate-linux` tool.
|
||||
|
||||
### 5. How to Test
|
||||
1. **Create Identity Backup**: Go to "Full Backup & Recovery" -> Select "Local File" -> "Export Backup". Save this file safely!
|
||||
2. **Cloud Upload**: Switch to "Cloud Backup" -> Encrypt with password -> "Upload Backup".
|
||||
3. **Restore Flow**:
|
||||
- Wipe app / New Device.
|
||||
- **Step 1**: "Import Backup" using your Local File (Restores Identity/Keys).
|
||||
- **Step 2**: "Download & Restore" from Cloud (Restores Message History).
|
||||
Loading…
Reference in a new issue