diff --git a/sojorn_app/assets/images/toplogo.png b/sojorn_app/assets/images/toplogo.png
new file mode 100644
index 0000000..4c2370c
Binary files /dev/null and b/sojorn_app/assets/images/toplogo.png differ
diff --git a/sojorn_app/assets/reactions/README.md b/sojorn_app/assets/reactions/README.md
new file mode 100644
index 0000000..48eb30f
--- /dev/null
+++ b/sojorn_app/assets/reactions/README.md
@@ -0,0 +1,209 @@
+# Custom Reaction Sets
+
+This folder contains custom reaction image sets that users can choose from in the reaction picker.
+
+## Folder Structure
+
+```
+assets/reactions/
+├── reaction_config.json # Configuration file for reaction sets
+├── emoji/ # Default emoji reactions (built-in)
+├── green/ # Green-themed reaction set
+│ ├── credit.md # Attribution information
+│ └── *.png # Reaction images
+├── blue/ # Blue-themed reaction set
+│ ├── credit.md # Attribution information
+│ └── *.png # Reaction images
+├── purple/ # Purple-themed reaction set
+│ ├── credit.md # Attribution information
+│ └── *.png # Reaction images
+├── dotto/ # Dotto pixel art emoji set
+│ ├── credit.md # Attribution information
+│ └── *.svg # Reaction images (SVG format)
+└── [custom]/ # Add your own themed sets here
+ ├── credit.md # Attribution information
+ └── *.png/.svg # Reaction images
+```
+
+## Adding Custom Reaction Sets
+
+### 1. Create a New Folder
+Create a new folder in `assets/reactions/` with your theme name:
+```bash
+mkdir assets/reactions/your_theme_name
+```
+
+### 2. Add Reaction Images
+Place your reaction images in the folder. Recommended specifications:
+- **Format**: PNG with transparency OR SVG for scalable graphics
+- **Size**: 64x64px (32x32px artwork) for PNG
+- **Naming**: Use descriptive names (system tries 200+ common names):
+ - `heart.png/svg`, `thumbs_up.png/svg`, `laugh.png/svg`
+ - `smiling_face.png/svg`, `winking_face.png/svg`
+ - `skull.png/svg`, `ghost.png/svg`, `robot.png/svg`
+ - `fire.png/svg`, `party.png/svg`, `clap.png/svg`
+ - And many more! (see comprehensive list in code)
+
+### 3. Add Credit Information (Optional)
+Create a `credit.md` file in your folder with attribution information:
+
+```markdown
+# Your Theme Name
+
+**Source:** [Link to source if applicable](URL)
+
+**Author:** Your Name or Artist
+
+**License:** License information
+
+**Description:** Brief description of your reaction set
+
+**Format:** PNG/SVG
+```
+
+### 4. Update Reaction Picker (Required!)
+
+After adding files to folders, run the update script:
+
+**Windows:**
+```bash
+tools\add_new_folder.bat
+```
+
+**Manual:**
+```bash
+dart tools/add_reaction_folder.dart
+```
+
+This script:
+- **Scans all folders** in `assets/reactions/`
+- **Checks for content** (PNG/SVG files)
+- **Updates the code** to include folders with content
+- **Ignores empty folders** automatically
+
+**Example Output:**
+```
+🔍 Scanning for new reaction folders...
+📁 Found folders: blue, dotto, green, purple
+✅ dotto: Has content
+⚠️ blue: Empty (will be ignored)
+📝 Updated reaction_picker.dart with folders: dotto
+🎉 Done! Restart your app to see the new reaction tabs
+```
+
+### 5. Restart App
+
+Restart your Flutter app to see the new reaction tabs!
+
+## Configuration Options
+
+### Generated Configuration Format
+
+The `generate_reaction_config.dart` script automatically creates a configuration like this:
+
+```json
+{
+ "reaction_sets": {
+ "emoji": {
+ "type": "emoji",
+ "reactions": ["❤️", "👍", "😂", ...]
+ },
+ "your_theme": {
+ "type": "folder",
+ "folder": "your_theme",
+ "file_types": ["png", "svg"],
+ "files": ["heart.png", "thumbs_up.png", ...]
+ }
+ },
+ "generated_at": "2026-02-01T15:16:02.195009",
+ "total_sets": 2
+}
+```
+
+### Configuration Fields
+
+- **type**: `"emoji"` or `"folder"`
+- **folder**: Folder name (for folder type)
+- **file_types**: Array of supported file extensions found
+- **files**: Explicit list of all files found in the folder
+- **generated_at**: When the config was last updated
+- **total_sets**: Number of reaction sets
+
+### Build-Time vs Runtime
+
+**Important**: Flutter assets are bundled at build time, so:
+- **File Discovery**: Happens when you run the generator script
+- **Runtime Loading**: Uses the explicit file list from config
+- **No Directory Scanning**: Cannot scan folders at runtime
+- **Manual Updates**: Run script when adding/removing files
+
+## Current Reaction Sets
+
+### Emoji (Default)
+Standard Unicode emoji reactions that work on all devices.
+
+### Green
+Green-themed custom reaction set with PNG images.
+
+### Blue
+Blue-themed custom reaction set with PNG images.
+
+### Purple
+Purple-themed custom reaction set with PNG images.
+
+### Dotto
+16x16 pixel art emoji set with SVG graphics from the Dotto Emoji project.
+
+## Image Guidelines
+
+### Recommended Dimensions
+- **PNG**: 64x64px canvas, 32-48px artwork (centered)
+- **SVG**: Any size (scalable)
+- **Format**: PNG with transparency OR SVG
+- **Background**: Transparent
+
+### Design Tips
+- **Consistent Style**: All reactions in a set should have the same visual style
+- **Clear Recognition**: Icons should be easily recognizable at small sizes
+- **Good Contrast**: Work well on both light and dark backgrounds
+- **Rounded Design**: Match the app's rounded aesthetic
+
+### File Naming Convention
+Use lowercase with underscores:
+- `heart.png` (not `Heart.png`)
+- `thumbs_up.png` (not `thumbsUp.png`)
+- `thinking_face.png` (not `thinkingFace.png`)
+
+## Credit System
+
+Each reaction folder can include a `credit.md` file that:
+- Displays at the bottom of the reaction tab
+- Supports basic markdown formatting
+- Provides attribution to content creators
+- Shows automatically when users view the reaction set
+
+### Markdown Support
+- Headers: `# Title`
+- Bold: `**text**`
+- Italic: `*text*`
+- Lists: `- item`
+
+## Testing
+
+The system includes robust error handling:
+- If an image file is missing, it shows a placeholder icon
+- If credit.md is missing, it shows default credit text
+- If configuration is invalid, it falls back to emoji-only
+- The reaction picker continues to function normally
+
+## Asset Configuration
+
+The `pubspec.yaml` only needs the parent folder:
+```yaml
+flutter:
+ uses-material-design: true
+ assets:
+ - assets/reactions/
+```
+
+This automatically includes all subfolders and files.
diff --git a/sojorn_app/assets/reactions/blue/credit.md b/sojorn_app/assets/reactions/blue/credit.md
new file mode 100644
index 0000000..eabe1ff
--- /dev/null
+++ b/sojorn_app/assets/reactions/blue/credit.md
@@ -0,0 +1,11 @@
+# Blue Reaction Set
+
+**Source:** Custom created for Sojorn
+
+**Description:** Blue-themed reaction icons with transparency
+
+**Format:** PNG (64x64px recommended)
+
+**Style:** Clean, modern blue color scheme
+
+**Usage:** Great for calm, professional, and thoughtful reactions
diff --git a/sojorn_app/assets/reactions/dotto/anguished_face.svg b/sojorn_app/assets/reactions/dotto/anguished_face.svg
new file mode 100644
index 0000000..121f690
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/anguished_face.svg
@@ -0,0 +1,251 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/beaming_face.svg b/sojorn_app/assets/reactions/dotto/beaming_face.svg
new file mode 100644
index 0000000..6699353
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/beaming_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/blue_heart.svg b/sojorn_app/assets/reactions/dotto/blue_heart.svg
new file mode 100644
index 0000000..07db848
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/blue_heart.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/broken_heart.svg b/sojorn_app/assets/reactions/dotto/broken_heart.svg
new file mode 100644
index 0000000..6747248
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/broken_heart.svg
@@ -0,0 +1,154 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/credit.md b/sojorn_app/assets/reactions/dotto/credit.md
new file mode 100644
index 0000000..e5314fd
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/credit.md
@@ -0,0 +1,11 @@
+# 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
diff --git a/sojorn_app/assets/reactions/dotto/disappointed_face.svg b/sojorn_app/assets/reactions/dotto/disappointed_face.svg
new file mode 100644
index 0000000..c0ffa46
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/disappointed_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/downcast_face.svg b/sojorn_app/assets/reactions/dotto/downcast_face.svg
new file mode 100644
index 0000000..b4c27a6
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/downcast_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/download_dotto_emoji.py b/sojorn_app/assets/reactions/dotto/download_dotto_emoji.py
new file mode 100644
index 0000000..a2c08bb
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/download_dotto_emoji.py
@@ -0,0 +1,93 @@
+#!/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()
diff --git a/sojorn_app/assets/reactions/dotto/face_with_monocle.svg b/sojorn_app/assets/reactions/dotto/face_with_monocle.svg
new file mode 100644
index 0000000..8726423
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/face_with_monocle.svg
@@ -0,0 +1,157 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/face_with_tears.svg b/sojorn_app/assets/reactions/dotto/face_with_tears.svg
new file mode 100644
index 0000000..08e17e2
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/face_with_tears.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/fearful_face.svg b/sojorn_app/assets/reactions/dotto/fearful_face.svg
new file mode 100644
index 0000000..8e6fd1e
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/fearful_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/ghost.svg b/sojorn_app/assets/reactions/dotto/ghost.svg
new file mode 100644
index 0000000..e820c73
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/ghost.svg
@@ -0,0 +1,157 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/green_heart.svg b/sojorn_app/assets/reactions/dotto/green_heart.svg
new file mode 100644
index 0000000..5124a2f
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/green_heart.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/grinning_face.svg b/sojorn_app/assets/reactions/dotto/grinning_face.svg
new file mode 100644
index 0000000..9cb2a42
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/grinning_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/heart_with_arrow.svg b/sojorn_app/assets/reactions/dotto/heart_with_arrow.svg
new file mode 100644
index 0000000..ec7a704
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/heart_with_arrow.svg
@@ -0,0 +1,195 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/laughing_face.svg b/sojorn_app/assets/reactions/dotto/laughing_face.svg
new file mode 100644
index 0000000..08e17e2
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/laughing_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/loudly_crying_face.svg b/sojorn_app/assets/reactions/dotto/loudly_crying_face.svg
new file mode 100644
index 0000000..a782abf
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/loudly_crying_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/melting_face.svg b/sojorn_app/assets/reactions/dotto/melting_face.svg
new file mode 100644
index 0000000..fda6229
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/melting_face.svg
@@ -0,0 +1,180 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/nerd_face.svg b/sojorn_app/assets/reactions/dotto/nerd_face.svg
new file mode 100644
index 0000000..7461f86
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/nerd_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/party_face.svg b/sojorn_app/assets/reactions/dotto/party_face.svg
new file mode 100644
index 0000000..e88204f
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/party_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/purple_heart.svg b/sojorn_app/assets/reactions/dotto/purple_heart.svg
new file mode 100644
index 0000000..6caad86
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/purple_heart.svg
@@ -0,0 +1,154 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/robot_face.svg b/sojorn_app/assets/reactions/dotto/robot_face.svg
new file mode 100644
index 0000000..b2d358f
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/robot_face.svg
@@ -0,0 +1,154 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/rolling_face.svg b/sojorn_app/assets/reactions/dotto/rolling_face.svg
new file mode 100644
index 0000000..9677353
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/rolling_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/skull.svg b/sojorn_app/assets/reactions/dotto/skull.svg
new file mode 100644
index 0000000..cca4167
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/skull.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/slightly_smiling_face.svg b/sojorn_app/assets/reactions/dotto/slightly_smiling_face.svg
new file mode 100644
index 0000000..644988a
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/slightly_smiling_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/smiling_face.svg b/sojorn_app/assets/reactions/dotto/smiling_face.svg
new file mode 100644
index 0000000..2c47d08
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/smiling_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/smiling_face_with_halo.svg b/sojorn_app/assets/reactions/dotto/smiling_face_with_halo.svg
new file mode 100644
index 0000000..14b80af
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/smiling_face_with_halo.svg
@@ -0,0 +1,161 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/smiling_face_with_hearts.svg b/sojorn_app/assets/reactions/dotto/smiling_face_with_hearts.svg
new file mode 100644
index 0000000..4fa36bd
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/smiling_face_with_hearts.svg
@@ -0,0 +1,169 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/sparkling_heart.svg b/sojorn_app/assets/reactions/dotto/sparkling_heart.svg
new file mode 100644
index 0000000..b1597ab
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/sparkling_heart.svg
@@ -0,0 +1,161 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/sunglasses_face.svg b/sojorn_app/assets/reactions/dotto/sunglasses_face.svg
new file mode 100644
index 0000000..f529fa4
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/sunglasses_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/upside_down_face.svg b/sojorn_app/assets/reactions/dotto/upside_down_face.svg
new file mode 100644
index 0000000..f75d113
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/upside_down_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/winking_face.svg b/sojorn_app/assets/reactions/dotto/winking_face.svg
new file mode 100644
index 0000000..0fe1ee2
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/winking_face.svg
@@ -0,0 +1,151 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/dotto/worried_face.svg b/sojorn_app/assets/reactions/dotto/worried_face.svg
new file mode 100644
index 0000000..8ea3088
--- /dev/null
+++ b/sojorn_app/assets/reactions/dotto/worried_face.svg
@@ -0,0 +1,31 @@
+
+
\ No newline at end of file
diff --git a/sojorn_app/assets/reactions/green/credit.md b/sojorn_app/assets/reactions/green/credit.md
new file mode 100644
index 0000000..fe5a2c6
--- /dev/null
+++ b/sojorn_app/assets/reactions/green/credit.md
@@ -0,0 +1,11 @@
+# Green Reaction Set
+
+**Source:** Custom created for Sojorn
+
+**Description:** Green-themed reaction icons with transparency
+
+**Format:** PNG (64x64px recommended)
+
+**Style:** Clean, modern green color scheme
+
+**Usage:** Perfect for nature, growth, and positive reactions
diff --git a/sojorn_app/assets/reactions/purple/credit.md b/sojorn_app/assets/reactions/purple/credit.md
new file mode 100644
index 0000000..e0c743b
--- /dev/null
+++ b/sojorn_app/assets/reactions/purple/credit.md
@@ -0,0 +1,11 @@
+# Purple Reaction Set
+
+**Source:** Custom created for Sojorn
+
+**Description:** Purple-themed reaction icons with transparency
+
+**Format:** PNG (64x64px recommended)
+
+**Style:** Clean, modern purple color scheme
+
+**Usage:** Ideal for creative, elegant, and expressive reactions
diff --git a/sojorn_app/assets/reactions/reaction_config.json b/sojorn_app/assets/reactions/reaction_config.json
new file mode 100644
index 0000000..9c6700a
--- /dev/null
+++ b/sojorn_app/assets/reactions/reaction_config.json
@@ -0,0 +1,75 @@
+{
+ "reaction_sets": {
+ "emoji": {
+ "type": "emoji",
+ "reactions": [
+ "❤️",
+ "👍",
+ "😂",
+ "😮",
+ "😢",
+ "😡",
+ "🎉",
+ "🔥",
+ "👏",
+ "🙏",
+ "💯",
+ "🤔",
+ "😍",
+ "🤣",
+ "😊",
+ "👌",
+ "🙌",
+ "💪",
+ "🎯",
+ "⭐",
+ "✨",
+ "🌟",
+ "💫",
+ "☀️"
+ ]
+ },
+ "dotto": {
+ "type": "folder",
+ "folder": "dotto",
+ "file_types": [
+ "svg"
+ ],
+ "files": [
+ "anguished_face.svg",
+ "beaming_face.svg",
+ "blue_heart.svg",
+ "broken_heart.svg",
+ "disappointed_face.svg",
+ "downcast_face.svg",
+ "face_with_monocle.svg",
+ "face_with_tears.svg",
+ "fearful_face.svg",
+ "ghost.svg",
+ "green_heart.svg",
+ "grinning_face.svg",
+ "heart_with_arrow.svg",
+ "laughing_face.svg",
+ "loudly_crying_face.svg",
+ "melting_face.svg",
+ "nerd_face.svg",
+ "party_face.svg",
+ "purple_heart.svg",
+ "robot_face.svg",
+ "rolling_face.svg",
+ "skull.svg",
+ "slightly_smiling_face.svg",
+ "smiling_face.svg",
+ "smiling_face_with_halo.svg",
+ "smiling_face_with_hearts.svg",
+ "sparkling_heart.svg",
+ "sunglasses_face.svg",
+ "upside_down_face.svg",
+ "winking_face.svg",
+ "worried_face.svg"
+ ]
+ }
+ },
+ "generated_at": "2026-02-01T15:16:54.716500",
+ "total_sets": 2
+}
\ No newline at end of file
diff --git a/sojorn_app/lib/models/sojorn_media_result.dart b/sojorn_app/lib/models/sojorn_media_result.dart
index ca8a80e..b7e8e12 100644
--- a/sojorn_app/lib/models/sojorn_media_result.dart
+++ b/sojorn_app/lib/models/sojorn_media_result.dart
@@ -12,13 +12,17 @@ class SojornMediaResult {
/// The file path (used for mobile/desktop operations)
final String? filePath;
- /// Media type ('image' or 'video')
+ /// The thumbnail file path (used for beacon images)
+ final String? thumbnailPath;
+
+ /// Media type ('image', 'video', or 'beacon_image')
final String mediaType;
const SojornMediaResult({
this.bytes,
this.name,
this.filePath,
+ this.thumbnailPath,
required this.mediaType,
}) : assert(bytes != null || filePath != null, 'Either bytes or filePath must be provided');
@@ -50,8 +54,24 @@ class SojornMediaResult {
);
}
+ /// Creates a result for beacon image with dual outputs
+ factory SojornMediaResult.beaconImage({
+ String? filePath,
+ String? thumbnailPath,
+ String? name,
+ }) {
+ return SojornMediaResult(
+ filePath: filePath,
+ thumbnailPath: thumbnailPath,
+ name: name,
+ mediaType: 'beacon_image',
+ );
+ }
+
bool get isImage => mediaType == 'image';
bool get isVideo => mediaType == 'video';
+ bool get isBeaconImage => mediaType == 'beacon_image';
bool get hasBytes => bytes != null;
bool get hasFilePath => filePath != null;
+ bool get hasThumbnail => thumbnailPath != null;
}
diff --git a/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart b/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart
index 3f5024c..3ad0c5f 100644
--- a/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart
+++ b/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart
@@ -51,7 +51,7 @@ class _BeaconBottomSheetState extends ConsumerState {
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
- color: statusColor.withOpacity(0.2),
+ color: statusColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: statusColor),
),
@@ -76,7 +76,7 @@ class _BeaconBottomSheetState extends ConsumerState {
Text(
post.beaconType?.displayName ?? 'Community',
style: theme.textTheme.labelMedium?.copyWith(
- color: theme.textTheme.bodySmall?.color?.withOpacity(0.7),
+ color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7),
),
),
const SizedBox(height: 4),
@@ -92,10 +92,15 @@ class _BeaconBottomSheetState extends ConsumerState {
CircleAvatar(
radius: 16,
backgroundColor: accentColor,
- child: Text(
- (post.author?.displayName ?? post.author?.handle ?? '?')[0].toUpperCase(),
- style: const TextStyle(color: Colors.white),
- ),
+ backgroundImage: (post.author?.avatarUrl != null && post.author!.avatarUrl!.isNotEmpty)
+ ? NetworkImage(post.author!.avatarUrl!)
+ : null,
+ child: (post.author?.avatarUrl == null || post.author!.avatarUrl!.isEmpty)
+ ? Text(
+ ((post.author?.displayName ?? post.author?.handle ?? '?')[0]).toUpperCase(),
+ style: const TextStyle(color: Colors.white),
+ )
+ : null,
),
const SizedBox(width: 12),
Column(
@@ -110,7 +115,7 @@ class _BeaconBottomSheetState extends ConsumerState {
Text(
'${_getFormattedDistance(post.distanceMeters)} • ${_getTimeAgo(post.createdAt)}',
style: theme.textTheme.bodySmall?.copyWith(
- color: theme.textTheme.bodySmall?.color?.withOpacity(0.6),
+ color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
@@ -157,7 +162,7 @@ class _BeaconBottomSheetState extends ConsumerState {
child: LinearProgressIndicator(
value: confidenceScore,
minHeight: 6,
- backgroundColor: Colors.grey.withOpacity(0.3),
+ backgroundColor: Colors.grey.withValues(alpha: 0.3),
valueColor: AlwaysStoppedAnimation(statusColor),
),
),
@@ -165,7 +170,7 @@ class _BeaconBottomSheetState extends ConsumerState {
Text(
'Confidence ${(confidenceScore * 100).toStringAsFixed(0)}%',
style: theme.textTheme.bodySmall?.copyWith(
- color: theme.textTheme.bodySmall?.color?.withOpacity(0.6),
+ color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
diff --git a/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart b/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart
new file mode 100644
index 0000000..ece1690
--- /dev/null
+++ b/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart
@@ -0,0 +1,551 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../models/post.dart';
+import '../../models/beacon.dart';
+import '../../providers/api_provider.dart';
+import '../../theme/app_theme.dart';
+import '../../widgets/sojorn_app_bar.dart';
+
+class BeaconDetailScreen extends ConsumerStatefulWidget {
+ final Post beaconPost;
+
+ const BeaconDetailScreen({super.key, required this.beaconPost});
+
+ @override
+ ConsumerState createState() => _BeaconDetailScreenState();
+}
+
+class _BeaconDetailScreenState extends ConsumerState
+ with SingleTickerProviderStateMixin {
+ late AnimationController _pulseController;
+ late Animation _pulseAnimation;
+ bool _isVouching = false;
+ bool _isReporting = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _pulseController = AnimationController(
+ duration: const Duration(milliseconds: 1500),
+ vsync: this,
+ );
+
+ final beacon = widget.beaconPost.toBeacon();
+ final confidenceScore = beacon.confidenceScore;
+
+ if (confidenceScore < 0.3) {
+ // Low confidence - add warning pulse
+ _pulseAnimation = Tween(
+ begin: 0.8,
+ end: 1.0,
+ ).animate(CurvedAnimation(
+ parent: _pulseController,
+ curve: Curves.easeInOut,
+ ));
+ _pulseController.repeat(reverse: true);
+ } else if (confidenceScore > 0.7) {
+ // High confidence - solid, no pulse
+ _pulseAnimation = Tween(
+ begin: 1.0,
+ end: 1.0,
+ ).animate(CurvedAnimation(
+ parent: _pulseController,
+ curve: Curves.linear,
+ ));
+ } else {
+ // Medium confidence - subtle pulse
+ _pulseAnimation = Tween(
+ begin: 0.9,
+ end: 1.0,
+ ).animate(CurvedAnimation(
+ parent: _pulseController,
+ curve: Curves.easeInOut,
+ ));
+ _pulseController.repeat(reverse: true);
+ }
+ }
+
+ @override
+ void dispose() {
+ _pulseController.dispose();
+ super.dispose();
+ }
+
+ Beacon get _beacon => widget.beaconPost.toBeacon();
+ Post get _post => widget.beaconPost;
+
+ Color get _statusColor {
+ switch (_beacon.status) {
+ case BeaconStatus.green:
+ return Colors.green;
+ case BeaconStatus.yellow:
+ return Colors.orange;
+ case BeaconStatus.red:
+ return Colors.red;
+ }
+ }
+
+ String get _statusLabel {
+ switch (_beacon.status) {
+ case BeaconStatus.green:
+ return 'Verified';
+ case BeaconStatus.yellow:
+ return 'Caution';
+ case BeaconStatus.red:
+ return 'Unverified';
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: CustomScrollView(
+ slivers: [
+ // Immersive Header with Image
+ _buildImmersiveHeader(),
+
+ // Content
+ SliverToBoxAdapter(
+ child: Container(
+ decoration: BoxDecoration(
+ color: AppTheme.scaffoldBg,
+ borderRadius: const BorderRadius.vertical(
+ top: Radius.circular(24),
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildStatusSection(),
+ _buildBeaconInfo(),
+ _buildAuthorInfo(),
+ const SizedBox(height: 32),
+ _buildActionButtons(),
+ const SizedBox(height: 24),
+ _buildConfidenceIndicator(),
+ const SizedBox(height: 40),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildImmersiveHeader() {
+ final hasImage = _post.imageUrl != null && _post.imageUrl!.isNotEmpty;
+
+ return SliverAppBar(
+ expandedHeight: hasImage ? 300 : 120,
+ pinned: true,
+ backgroundColor: Colors.transparent,
+ leading: Container(
+ margin: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Colors.black26,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: IconButton(
+ onPressed: () => Navigator.of(context).pop(),
+ icon: const Icon(Icons.arrow_back, color: Colors.white),
+ ),
+ ),
+ flexibleSpace: FlexibleSpaceBar(
+ background: Stack(
+ fit: StackFit.expand,
+ children: [
+ if (hasImage)
+ Hero(
+ tag: 'beacon-image-${_post.id}',
+ child: Image.network(
+ _post.imageUrl!,
+ fit: BoxFit.cover,
+ errorBuilder: (context, error, stackTrace) {
+ return _buildFallbackImage();
+ },
+ loadingBuilder: (context, child, loadingProgress) {
+ if (loadingProgress == null) return child;
+ return Container(
+ color: AppTheme.navyBlue,
+ child: Center(
+ child: CircularProgressIndicator(
+ color: Colors.white,
+ value: loadingProgress.expectedTotalBytes != null
+ ? loadingProgress.cumulativeBytesLoaded /
+ loadingProgress.expectedTotalBytes!
+ : null,
+ ),
+ ),
+ );
+ },
+ ),
+ )
+ else
+ _buildFallbackImage(),
+
+ // Gradient overlay for text readability
+ Container(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Colors.black.withValues(alpha: 0.7),
+ Colors.transparent,
+ Colors.black.withValues(alpha: 0.3),
+ ],
+ ),
+ ),
+ ),
+
+ // Status indicator with pulse
+ Positioned(
+ top: 60,
+ right: 16,
+ child: AnimatedBuilder(
+ animation: _pulseAnimation,
+ builder: (context, child) {
+ return Transform.scale(
+ scale: _pulseAnimation.value,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: _statusColor.withValues(alpha: 0.9),
+ borderRadius: BorderRadius.circular(20),
+ boxShadow: [
+ BoxShadow(
+ color: _statusColor.withValues(alpha: 0.3),
+ blurRadius: 8,
+ spreadRadius: 2,
+ ),
+ ],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ _beacon.status == BeaconStatus.green
+ ? Icons.verified
+ : Icons.warning,
+ color: Colors.white,
+ size: 16,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ _statusLabel,
+ style: const TextStyle(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildFallbackImage() {
+ return Container(
+ color: AppTheme.navyBlue,
+ child: Icon(
+ _beacon.beaconType.icon,
+ color: Colors.white,
+ size: 80,
+ ),
+ );
+ }
+
+ Widget _buildStatusSection() {
+ return Padding(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: _beacon.beaconType.color.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(
+ _beacon.beaconType.icon,
+ color: _beacon.beaconType.color,
+ size: 24,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ _beacon.beaconType.displayName,
+ style: AppTheme.headlineSmall,
+ ),
+ Text(
+ '${_beacon.getFormattedDistance()} away',
+ style: AppTheme.bodyMedium?.copyWith(
+ color: AppTheme.textDisabled,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ Text(
+ _post.body,
+ style: AppTheme.postBody,
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildBeaconInfo() {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20),
+ child: Row(
+ children: [
+ Icon(
+ Icons.access_time,
+ color: AppTheme.textDisabled,
+ size: 16,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ _beacon.getTimeAgo(),
+ style: AppTheme.bodyMedium?.copyWith(
+ color: AppTheme.textDisabled,
+ ),
+ ),
+ const Spacer(),
+ if (_post.latitude != null && _post.longitude != null) ...[
+ Icon(
+ Icons.location_on,
+ color: AppTheme.textDisabled,
+ size: 16,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ 'Location tagged',
+ style: AppTheme.bodyMedium?.copyWith(
+ color: AppTheme.textDisabled,
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _buildAuthorInfo() {
+ final author = _post.author;
+ if (author == null) return const SizedBox.shrink();
+
+ return Padding(
+ padding: const EdgeInsets.all(20),
+ child: Row(
+ children: [
+ CircleAvatar(
+ radius: 20,
+ backgroundColor: AppTheme.egyptianBlue,
+ backgroundImage: (author.avatarUrl != null && author.avatarUrl!.isNotEmpty)
+ ? NetworkImage(author.avatarUrl!)
+ : null,
+ child: (author.avatarUrl == null || author.avatarUrl!.isEmpty)
+ ? Text(
+ ((author.displayName ?? author.handle ?? '?')[0]).toUpperCase(),
+ style: const TextStyle(color: Colors.white),
+ )
+ : null,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ author.displayName ?? '@${author.handle ?? 'unknown'}',
+ style: AppTheme.headlineSmall,
+ ),
+ if (author.handle != null && author.handle!.isNotEmpty)
+ Text(
+ '@${author.handle}',
+ style: AppTheme.bodyMedium?.copyWith(
+ color: AppTheme.textDisabled,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildActionButtons() {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20),
+ child: Column(
+ children: [
+ // Vouch Button
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton.icon(
+ onPressed: _isVouching ? null : () => _vouchBeacon(),
+ icon: _isVouching
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : const Icon(Icons.thumb_up),
+ label: Text(_isVouching ? 'Confirming...' : 'Vouch for this Beacon'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.green,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ // Report Button
+ SizedBox(
+ width: double.infinity,
+ child: OutlinedButton.icon(
+ onPressed: _isReporting ? null : () => _reportBeacon(),
+ icon: _isReporting
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.red,
+ ),
+ )
+ : const Icon(Icons.thumb_down),
+ label: Text(_isReporting ? 'Reporting...' : 'Report Issue'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: Colors.red,
+ side: const BorderSide(color: Colors.red),
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildConfidenceIndicator() {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Community Confidence',
+ style: AppTheme.labelMedium,
+ ),
+ const SizedBox(height: 8),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: LinearProgressIndicator(
+ value: _beacon.confidenceScore,
+ minHeight: 8,
+ backgroundColor: Colors.grey.withValues(alpha: 0.3),
+ valueColor: AlwaysStoppedAnimation(_statusColor),
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Confidence ${(_beacon.confidenceScore * 100).toStringAsFixed(0)}%',
+ style: AppTheme.labelSmall?.copyWith(
+ color: AppTheme.textDisabled,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _vouchBeacon() async {
+ final apiService = ref.read(apiServiceProvider);
+ setState(() => _isVouching = true);
+
+ try {
+ await apiService.vouchBeacon(_post.id);
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Thanks for confirming this beacon!'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ Navigator.of(context).pop();
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Something went wrong: $e'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _isVouching = false);
+ }
+ }
+
+ Future _reportBeacon() async {
+ final apiService = ref.read(apiServiceProvider);
+ setState(() => _isReporting = true);
+
+ try {
+ await apiService.reportBeacon(_post.id);
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Report received. Thanks for keeping the community safe.'),
+ backgroundColor: Colors.orange,
+ ),
+ );
+ Navigator.of(context).pop();
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Something went wrong: $e'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _isReporting = false);
+ }
+ }
+}
diff --git a/sojorn_app/lib/screens/beacon/beacon_screen.dart b/sojorn_app/lib/screens/beacon/beacon_screen.dart
index 3448be6..c2b5120 100644
--- a/sojorn_app/lib/screens/beacon/beacon_screen.dart
+++ b/sojorn_app/lib/screens/beacon/beacon_screen.dart
@@ -11,6 +11,7 @@ import '../../services/local_intel_service.dart';
import '../../theme/app_theme.dart';
import 'package:go_router/go_router.dart';
import 'beacon_bottom_sheet.dart';
+import 'beacon_detail_screen.dart';
import 'create_beacon_sheet.dart';
import 'widgets/intel_cards.dart';
import 'widgets/resources_sheet.dart';
@@ -184,10 +185,10 @@ class _BeaconScreenState extends ConsumerState {
}
void _onMarkerTap(Post post) {
- showModalBottomSheet(
- context: context,
- backgroundColor: Colors.transparent,
- builder: (context) => BeaconBottomSheet(post: post),
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (context) => BeaconDetailScreen(beaconPost: post),
+ ),
);
}
@@ -692,6 +693,7 @@ class _BeaconScreenState extends ConsumerState {
Marker _createMarker(Post post) {
final typeColor = post.beaconType?.color ?? Colors.blue;
final typeIcon = post.beaconType?.icon ?? Icons.location_on;
+ final confidenceScore = post.confidenceScore ?? 0.5;
final fallbackBase = _userLocation ?? _mapCenter;
@@ -708,16 +710,10 @@ class _BeaconScreenState extends ConsumerState {
height: 48,
child: GestureDetector(
onTap: () => _onMarkerTap(post),
- child: Container(
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: typeColor,
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.3), blurRadius: 8),
- ],
- ),
- child: Icon(typeIcon, color: Colors.white, size: 28),
+ child: _BeaconMarker(
+ color: typeColor,
+ icon: typeIcon,
+ confidenceScore: confidenceScore,
),
),
);
@@ -733,6 +729,87 @@ class _BeaconScreenState extends ConsumerState {
}
}
+class _BeaconMarker extends StatefulWidget {
+ final Color color;
+ final IconData icon;
+ final double confidenceScore;
+
+ const _BeaconMarker({
+ required this.color,
+ required this.icon,
+ required this.confidenceScore,
+ });
+
+ @override
+ State<_BeaconMarker> createState() => _BeaconMarkerState();
+}
+
+class _BeaconMarkerState extends State<_BeaconMarker>
+ with SingleTickerProviderStateMixin {
+ late AnimationController _controller;
+ late Animation _pulseAnimation;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ duration: const Duration(milliseconds: 1500),
+ vsync: this,
+ );
+
+ if (widget.confidenceScore < 0.3) {
+ // Low confidence - warning pulse
+ _pulseAnimation = Tween(begin: 0.8, end: 1.0).animate(
+ CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
+ );
+ _controller.repeat(reverse: true);
+ } else if (widget.confidenceScore > 0.7) {
+ // High confidence - solid, no pulse
+ _pulseAnimation = Tween(begin: 1.0, end: 1.0).animate(
+ CurvedAnimation(parent: _controller, curve: Curves.linear),
+ );
+ } else {
+ // Medium confidence - subtle pulse
+ _pulseAnimation = Tween(begin: 0.9, end: 1.0).animate(
+ CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
+ );
+ _controller.repeat(reverse: true);
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedBuilder(
+ animation: _pulseAnimation,
+ builder: (context, child) {
+ return Transform.scale(
+ scale: _pulseAnimation.value,
+ child: Container(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: widget.color,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.3),
+ blurRadius: 8,
+ spreadRadius: widget.confidenceScore < 0.3 ? 2 : 0,
+ ),
+ ],
+ ),
+ child: Icon(widget.icon, color: Colors.white, size: 28),
+ ),
+ );
+ },
+ );
+ }
+}
+
class _PulsingLocationIndicator extends StatefulWidget {
@override
State<_PulsingLocationIndicator> createState() =>
diff --git a/sojorn_app/lib/screens/compose/image_editor_screen.dart b/sojorn_app/lib/screens/compose/image_editor_screen.dart
index 7d07f1e..cb13bc2 100644
--- a/sojorn_app/lib/screens/compose/image_editor_screen.dart
+++ b/sojorn_app/lib/screens/compose/image_editor_screen.dart
@@ -14,12 +14,16 @@ class sojornImageEditor extends StatelessWidget {
final String? imagePath;
final Uint8List? imageBytes;
final String? imageName;
+ final bool isBeacon;
+ final String? postType;
const sojornImageEditor({
super.key,
this.imagePath,
this.imageBytes,
this.imageName,
+ this.isBeacon = false,
+ this.postType,
}) : assert(imagePath != null || imageBytes != null);
static const Color _matteBlack = Color(0xFF0B0B0B);
@@ -63,6 +67,9 @@ class sojornImageEditor extends StatelessWidget {
}
ProImageEditorConfigs _buildConfigs() {
+ // Determine aspect ratio based on post type
+ final aspectRatios = _getAspectRatios();
+
return ProImageEditorConfigs(
theme: _buildEditorTheme(),
imageEditorTheme: ImageEditorTheme(
@@ -156,7 +163,7 @@ class sojornImageEditor extends StatelessWidget {
),
TextButton(
onPressed: editor.doneEditing,
- child: const Text('Save'),
+ child: Text(isBeacon ? 'Save Beacon' : 'Save'),
),
const SizedBox(width: 6),
],
@@ -168,6 +175,28 @@ class sojornImageEditor extends StatelessWidget {
);
}
+ Map _getAspectRatios() {
+ if (isBeacon) {
+ // Beacon: Allow original and free ratios to preserve context
+ return {
+ 'options': const [
+ 'original',
+ 'free',
+ '4:5',
+ '16:9',
+ ],
+ };
+ } else {
+ // Main Feed: Lock to square for consistency
+ return {
+ 'options': const [
+ '1:1',
+ 'free',
+ ],
+ };
+ }
+ }
+
@override
Widget build(BuildContext context) {
if (imageBytes != null) {
@@ -227,14 +256,33 @@ class sojornImageEditor extends StatelessWidget {
final file = File('${tempDir.path}/$fileName');
await file.writeAsBytes(editedBytes);
- if (!context.mounted) return;
- Navigator.pop(
- context,
- SojornMediaResult.image(
- filePath: file.path,
- name: fileName,
- ),
- );
+ // Generate dual outputs for beacons
+ if (isBeacon) {
+ final thumbnailFileName = 'sojorn_thumb_$timestamp.jpg';
+ final thumbnailFile = File('${tempDir.path}/$thumbnailFileName');
+
+ // Create 300x300 thumbnail
+ await _createThumbnail(editedBytes, thumbnailFile);
+
+ if (!context.mounted) return;
+ Navigator.pop(
+ context,
+ SojornMediaResult.beaconImage(
+ filePath: file.path,
+ thumbnailPath: thumbnailFile.path,
+ name: fileName,
+ ),
+ );
+ } else {
+ if (!context.mounted) return;
+ Navigator.pop(
+ context,
+ SojornMediaResult.image(
+ filePath: file.path,
+ name: fileName,
+ ),
+ );
+ }
} catch (e) {
if (!context.mounted) return;
Navigator.pop(
@@ -250,6 +298,27 @@ class sojornImageEditor extends StatelessWidget {
);
}
+ Future _createThumbnail(Uint8List originalBytes, File thumbnailFile) async {
+ // This is a placeholder for thumbnail generation
+ // In a real implementation, you would use an image processing library
+ // like 'image' package to resize the image to 300x300
+ try {
+ // For now, just write the original bytes as a placeholder
+ await thumbnailFile.writeAsBytes(originalBytes);
+
+ // TODO: Implement proper thumbnail generation using image package
+ // Example (requires adding 'image' package to pubspec.yaml):
+ // import 'package:image/image.dart' as img;
+ //
+ // final image = img.decodeImage(originalBytes)!;
+ // final thumbnail = img.copyResize(image, width: 300, height: 300);
+ // await thumbnailFile.writeAsBytes(img.encodeJpg(thumbnail, quality: 85));
+ } catch (e) {
+ // Fallback to original bytes if thumbnail generation fails
+ await thumbnailFile.writeAsBytes(originalBytes);
+ }
+ }
+
Widget _buildLoading() {
return Scaffold(
backgroundColor: _matteBlack,
diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart
index ffd575f..dd3d2ef 100644
--- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart
+++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart
@@ -5,12 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
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 '../../models/post.dart';
import '../../providers/api_provider.dart';
import '../../theme/app_theme.dart';
import '../../widgets/post/interactive_reply_block.dart';
import '../../widgets/media/signed_media_image.dart';
-import '../../widgets/reactions/reaction_strip.dart';
import '../compose/compose_screen.dart';
class ThreadedConversationScreen extends ConsumerStatefulWidget {
@@ -945,33 +946,36 @@ class _ThreadedConversationScreenState extends ConsumerState _openReactionPicker(String postId) async {
- final selection = await showReactionPicker(
- context,
- baseItems: const [
- ReactionItem(id: '👍', label: 'thumbs up'),
- ReactionItem(id: '❤️', label: 'heart'),
- ReactionItem(id: '😂', label: 'laugh'),
- ReactionItem(id: '😮', label: 'wow'),
- ReactionItem(id: '😢', label: 'sad'),
- ReactionItem(id: '😡', label: 'angry'),
- ReactionItem(id: '🙏', label: 'thanks'),
- ReactionItem(id: '🔥', label: 'fire'),
- ReactionItem(id: '🎉', label: 'party'),
- ReactionItem(id: '👀', label: 'eyes'),
- ReactionItem(id: '🤝', label: 'handshake'),
- ReactionItem(id: '✅', label: 'check'),
- ReactionItem(id: '🌿', label: 'leaf'),
- ReactionItem(id: '✨', label: 'sparkles'),
- ReactionItem(id: '🧠', label: 'brain'),
- ReactionItem(id: '🫶', label: 'care'),
- ],
- );
-
- if (selection == null || selection.isEmpty) return;
- _toggleReaction(postId, selection);
+ Post? _findPostById(String postId) {
+ if (widget.rootPost?.id == postId) return widget.rootPost;
+
+ // Search in focus context
+ if (_focusContext != null) {
+ if (_focusContext!.targetPost.id == postId) return _focusContext!.targetPost;
+ for (final post in _focusContext!.children) {
+ if (post.id == postId) return post;
+ }
+ }
+
+ return null;
}
+ Future _openReactionPicker(String postId) async {
+ final post = _findPostById(postId);
+ if (post == null) return;
+
+ showDialog(
+ context: context,
+ builder: (context) => ReactionPicker(
+ onReactionSelected: (emoji) {
+ _toggleReaction(postId, emoji);
+ },
+ reactionCounts: _reactionCountsFor(post),
+ myReactions: _myReactionsFor(post),
+ ),
+ );
+}
+
void _seedReactionState(FocusContext focusContext) {
_seedReactionsForPost(focusContext.targetPost);
if (focusContext.parentPost != null) {
diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart
index 9ab0d27..8bc3b7d 100644
--- a/sojorn_app/lib/services/api_service.dart
+++ b/sojorn_app/lib/services/api_service.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
-import '../models/category.dart';
+import 'package:flutter/foundation.dart';
+import '../models/category.dart' as models;
import '../models/profile.dart';
import '../models/follow_request.dart';
import '../models/profile_privacy_settings.dart';
@@ -13,6 +14,8 @@ import '../config/api_config.dart';
import '../services/auth_service.dart';
import '../models/search_results.dart';
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.
@@ -59,6 +62,7 @@ class ApiService {
String method = 'POST',
Map? queryParams,
Object? body,
+ bool requireSignature = false,
}) async {
try {
var uri = Uri.parse('${ApiConfig.baseUrl}$path')
@@ -66,18 +70,30 @@ class ApiService {
var headers = await _authHeaders();
headers['Content-Type'] = 'application/json';
+ // Add request signature for critical operations
+ if (requireSignature && body != null) {
+ final secretKey = _authService.currentUser?.id ?? 'default';
+ headers = RequestSigning.addSignatureHeaders(
+ headers,
+ method,
+ path,
+ body as Map?,
+ secretKey,
+ );
+ }
+
http.Response response =
await _performRequest(method, uri, headers, body);
// INTERCEPTOR: Handle 401
if (response.statusCode == 401) {
- print('[API] 401 Unauthorized at $path. Attempting refresh...');
+ if (kDebugMode) print('[API] Auth error at ${_sanitizePath(path)}');
final refreshed = await _authService.refreshSession();
if (refreshed) {
// Update token header and RETRY
headers = await _authHeaders();
headers['Content-Type'] = 'application/json';
- print('[API] Retrying request to $path with new token...');
+ if (kDebugMode) print('[API] Retrying request to ${_sanitizePath(path)}');
response = await _performRequest(method, uri, headers, body);
} else {
// Refresh failed, assume session died.
@@ -86,6 +102,7 @@ class ApiService {
}
if (response.statusCode >= 400) {
+ if (kDebugMode) print('[API] Error ${response.statusCode} at ${_sanitizePath(path)}');
throw Exception(
'Go API error (${response.statusCode}): ${response.body}');
}
@@ -95,7 +112,7 @@ class ApiService {
if (data is Map) return data;
return {'data': data};
} catch (e) {
- print('Go API call to $path failed: $e');
+ if (kDebugMode) print('[API] Call failed: ${_sanitizePath(path)} - $e');
rethrow;
}
}
@@ -129,9 +146,22 @@ class ApiService {
return {
'Authorization': 'Bearer $token',
+ 'X-Rate-Limit-Remaining': '100',
+ 'X-Rate-Limit-Reset': '3600',
+ 'X-Request-ID': _generateRequestId(),
};
}
+ /// Sanitize path for logging by removing sensitive IDs
+ String _sanitizePath(String path) {
+ return path.replaceAll(RegExp(r'/[a-zA-Z0-9_-]{20,}'), '/***');
+ }
+
+ /// Generate unique request ID for tracking
+ String _generateRequestId() {
+ return '${DateTime.now().millisecondsSinceEpoch}-${DateTime.now().microsecond}';
+ }
+
List