Fluid Platform Mobile Widget Implementation Guide
Overview
Mobile widgets on the Fluid platform allow you to create custom functionality that can be integrated into mobile apps through embedded web views. These widgets are web applications that maintain secure authentication and access to company data while being displayed within the mobile app interface.
Architecture Overview
Widget Components
- Standalone Web Application: Each widget is hosted at its own URL
- Authentication Layer: Secure token-based authentication via Fluid's auth token system
- Data Integration: Connection to backend systems (your existing databases, APIs, etc.)
- Mobile App Integration: Embedded via web views in mobile applications
Key Concepts
- Mobile Widgets: Custom components for mobile app integration via
MobileWidgetmodel - Authentication Tokens: Secure access tokens generated by
UserCompany#generate_auth_token - API Access: Widgets can access both Fluid APIs and your own backend APIs
- User Identification: Using
external_idfromUserCompanyas the primary identifier for business operations - Dynamic Height Adjustment: Widgets can dynamically resize themselves by posting messages to the mobile app via
ReactNativeWebView.postMessage()
Implementation Steps
Step 1: Widget Planning
Before implementation, consider:
- Widget Purpose: KPIs, volumes, bonus tracking, team performance, etc.
- Data Sources: Determine if data comes from Fluid or your existing systems
- Update Frequency: Real-time vs. periodic updates
- Required User Fields: Ensure users have
external_idfor business operations - Height Requirements: Plan for dynamic content and whether the widget needs to resize based on data (supports 100-1000px)
- Interactivity: Consider if users need to expand/collapse sections or interact with content that affects widget height
Step 2: Create the Widget Application
Basic HTML Structure
<!DOCTYPE html> <html> <head> <title>Widget Name</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <style> /* Mobile-optimized styles */ body { margin: 0; padding: 10px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .loading { text-align: center; padding: 20px; } .error { color: #e74c3c; padding: 20px; text-align: center; } .widget-content { padding: 15px; } .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 20px; } .metric { background: #f5f5f5; padding: 15px; border-radius: 8px; text-align: center; } .label { display: block; font-size: 12px; color: #666; margin-bottom: 5px; } .value { display: block; font-size: 20px; font-weight: bold; color: #333; } </style> </head> <body> <div id="widget-container"> <div class="loading">Loading...</div> </div> <script src="widget.js"></script> </body> </html>
JavaScript Implementation
// widget.js class FluidWidget { constructor() { this.authToken = this.getAuthToken(); this.fluidApiUrl = 'https://your-fluid-api.com/api'; // Configure this this.backendApiUrl = 'https://your-backend.com/api'; // Your backend this.init(); } getAuthToken() { // Extract auth token from URL parameters const urlParams = new URLSearchParams(window.location.search); return urlParams.get('auth_token'); } async init() { if (!this.authToken) { this.showError('No authentication token provided'); return; } try { // Validate token with Fluid and get user info const fluidUser = await this.getUserFromFluid(); if (!fluidUser) { this.showError('Failed to authenticate with Fluid'); return; } // Validate user has required fields if (!fluidUser.externalId) { this.showError('User account not properly configured (missing external ID)'); return; } // Fetch widget-specific data from your backend using external_id const widgetData = await this.fetchWidgetData(fluidUser); // Render the widget this.render(widgetData, fluidUser); } catch (error) { this.showError('Failed to load widget data'); console.error('Widget error:', error); } } async getUserFromFluid() { try { // Use the auth token in the URL path - the API accepts either user_company.id or auth_token.token // The auth_token is also passed as a Bearer token for authentication const response = await fetch(`${this.fluidApiUrl}/v2025-06/users/${this.authToken}`, { headers: { 'Authorization': `Bearer ${this.authToken}`, 'Content-Type': 'application/json' } }); if (!response.ok) { console.error('Fluid authentication failed:', response.status); return null; } const userData = await response.json(); // Map response to expected structure based on UserCompanyBlueprinter const userCompany = userData.user_company || userData; return { id: userCompany.id, externalId: userCompany.external_id, companyId: userCompany.company_id, email: userCompany.email, username: userCompany.username, roles: userCompany.roles, metadata: userCompany.metadata || {} }; } catch (error) { console.error('Error calling Fluid API:', error); return null; } } async fetchWidgetData(fluidUser) { // Use external_id as the primary identifier for your business logic const response = await fetch( `${this.backendApiUrl}/widget-data?external_id=${encodeURIComponent(fluidUser.externalId)}`, { headers: { 'Authorization': `Bearer ${this.authToken}`, 'X-Company-Id': fluidUser.companyId || '', 'Content-Type': 'application/json' } } ); if (!response.ok) { throw new Error('Failed to fetch widget data'); } return await response.json(); } render(data, fluidUser) { const container = document.getElementById('widget-container'); container.innerHTML = ` <div class="widget-content"> <h2>Welcome, ${data.userName || fluidUser.username}</h2> <div class="user-info"> <small>ID: ${fluidUser.externalId} | Company: ${fluidUser.companyId}</small> </div> <div class="metrics"> <div class="metric"> <span class="label">Personal Volume</span> <span class="value">$${(data.personalVolume || 0).toLocaleString()}</span> </div> <div class="metric"> <span class="label">Team Volume</span> <span class="value">$${(data.teamVolume || 0).toLocaleString()}</span> </div> <div class="metric"> <span class="label">Current Rank</span> <span class="value">${data.rank || 'N/A'}</span> </div> <div class="metric"> <span class="label">New Enrollments</span> <span class="value">${data.newEnrollments || 0}</span> </div> </div> </div> `; } showError(message) { const container = document.getElementById('widget-container'); container.innerHTML = `<div class="error">${message}</div>`; } } // Initialize widget when page loads document.addEventListener('DOMContentLoaded', () => { new FluidWidget(); });
Dynamic Height Adjustment
The mobile app supports dynamic height adjustment for widgets through the React Native WebView message interface. Your widget can request height changes by posting messages to the mobile app.
Message Format
interface EmbedMessage { type: 'HEIGHT_CHANGED'; height: number; }
Implementation in Your Widget
class FluidWidget { // ... existing constructor and methods ... /** * Request a height change from the mobile app * @param {number} height - Desired height in pixels */ requestHeightChange(height) { if (typeof window.ReactNativeWebView === 'undefined') { console.warn('Not running in React Native WebView - height change ignored'); return; } const message = { type: 'HEIGHT_CHANGED', height: height }; try { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } catch (error) { console.error('Failed to post message to mobile app:', error); } } /** * Automatically adjust height based on content */ autoAdjustHeight() { if (typeof window.ReactNativeWebView === 'undefined') { return; } // Get the actual content height const contentHeight = document.body.scrollHeight; this.requestHeightChange(contentHeight); } render(data, fluidUser) { // ... render your widget content ... // After rendering, adjust height if needed setTimeout(() => { this.autoAdjustHeight(); }, 100); } }
Height Constraints
The mobile app enforces height constraints on all widget height change requests:
- Minimum height: 100 pixels (MIN_WIDGET_HEIGHT)
- Maximum height: 1000 pixels (MAX_WIDGET_HEIGHT)
- Clamping: Heights outside this range are automatically clamped
- Animation: Height changes are animated with a 300ms transition
Practical Examples
Example 1: Resize After Data Load
class DynamicKPIWidget extends FluidWidget { async init() { if (!this.authToken) { this.showError('No authentication token provided'); return; } try { const fluidUser = await this.getUserFromFluid(); const widgetData = await this.fetchWidgetData(fluidUser); this.render(widgetData, fluidUser); // Resize widget to fit content after rendering this.resizeToFitContent(); } catch (error) { this.showError('Failed to load widget data'); console.error('Widget error:', error); } } resizeToFitContent() { // Wait for layout to complete requestAnimationFrame(() => { const contentHeight = document.body.scrollHeight; const padding = 20; // Add some padding this.requestHeightChange(contentHeight + padding); }); } }
Example 2: Expandable/Collapsible Widget
class ExpandableWidget extends FluidWidget { constructor() { super(); this.isExpanded = false; this.collapsedHeight = 200; this.expandedHeight = 600; } render(data, fluidUser) { const container = document.getElementById('widget-container'); container.innerHTML = ` <div class="widget-content"> <div class="widget-header"> <h2>Team Performance</h2> <button id="toggle-btn" class="toggle-button"> ${this.isExpanded ? 'Collapse' : 'Expand'} </button> </div> <div class="widget-body"> <div class="summary"> <!-- Always visible summary --> <p>Team Size: ${data.teamSize}</p> <p>Total Volume: $${data.totalVolume}</p> </div> <div id="details" class="details" style="display: ${this.isExpanded ? 'block' : 'none'}"> <!-- Expandable details --> <ul> ${data.members.map(m => `<li>${m.name}: $${m.volume}</li>`).join('')} </ul> </div> </div> </div> `; // Set initial height this.requestHeightChange(this.isExpanded ? this.expandedHeight : this.collapsedHeight); // Add toggle functionality document.getElementById('toggle-btn').addEventListener('click', () => { this.toggleExpanded(); }); } toggleExpanded() { this.isExpanded = !this.isExpanded; const details = document.getElementById('details'); const button = document.getElementById('toggle-btn'); if (this.isExpanded) { details.style.display = 'block'; button.textContent = 'Collapse'; this.requestHeightChange(this.expandedHeight); } else { details.style.display = 'none'; button.textContent = 'Expand'; this.requestHeightChange(this.collapsedHeight); } } }
Example 3: Responsive to Data Changes
class RealtimeWidget extends FluidWidget { constructor() { super(); this.updateInterval = 30000; // 30 seconds } async init() { if (!this.authToken) { this.showError('No authentication token provided'); return; } try { const fluidUser = await this.getUserFromFluid(); await this.loadAndRenderData(fluidUser); // Set up periodic updates setInterval(() => { this.loadAndRenderData(fluidUser); }, this.updateInterval); } catch (error) { this.showError('Failed to load widget data'); console.error('Widget error:', error); } } async loadAndRenderData(fluidUser) { const widgetData = await this.fetchWidgetData(fluidUser); this.render(widgetData, fluidUser); // Adjust height after each data update this.adjustHeightToContent(); } adjustHeightToContent() { // Use requestAnimationFrame to ensure DOM is updated requestAnimationFrame(() => { const contentHeight = document.body.scrollHeight; this.requestHeightChange(contentHeight); }); } }
Best Practices for Dynamic Height
Check for WebView Environment
if (typeof window.ReactNativeWebView !== 'undefined') { // Safe to use postMessage }
Delay Height Adjustments After Rendering
// Use requestAnimationFrame for accurate measurements requestAnimationFrame(() => { this.requestHeightChange(document.body.scrollHeight); });
Handle Errors Gracefully
try { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } catch (error) { console.error('Failed to adjust height:', error); // Widget continues to work with default height }
Respect Height Constraints
// Clamp height within valid range before requesting const MIN_HEIGHT = 100; const MAX_HEIGHT = 1000; const clampedHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, desiredHeight)); this.requestHeightChange(clampedHeight);
Debounce Rapid Height Changes
class FluidWidget { constructor() { super(); this.heightChangeTimeout = null; } requestHeightChange(height) { // Debounce rapid height changes clearTimeout(this.heightChangeTimeout); this.heightChangeTimeout = setTimeout(() => { if (typeof window.ReactNativeWebView !== 'undefined') { const message = { type: 'HEIGHT_CHANGED', height: height }; window.ReactNativeWebView.postMessage(JSON.stringify(message)); } }, 100); } }
Step 3: Backend API Implementation
Node.js/Express Example
const express = require('express'); const axios = require('axios'); const app = express(); // Configuration const FLUID_API_URL = process.env.FLUID_API_URL || 'https://your-fluid-api.com/api'; // Middleware to validate Fluid auth token and get user info async function validateFluidToken(req, res, next) { const authToken = req.headers.authorization?.replace('Bearer ', ''); if (!authToken) { return res.status(401).json({ error: 'No auth token provided' }); } try { // Validate token with Fluid API using auth token in URL path const response = await axios.get(`${FLUID_API_URL}/v2025-06/users/${authToken}`, { headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' } }); const fluidUser = response.data.user_company || response.data; // Validate user has required fields if (!fluidUser.external_id) { return res.status(400).json({ error: 'User missing external_id - cannot proceed with business operations' }); } // Attach user to request object req.fluidUser = { id: fluidUser.id, externalId: fluidUser.external_id, companyId: fluidUser.company_id, email: fluidUser.email, username: fluidUser.username, roles: fluidUser.roles, metadata: fluidUser.metadata || {} }; next(); } catch (error) { if (error.response?.status === 401) { return res.status(401).json({ error: 'Invalid or expired token' }); } else if (error.response?.status === 404) { return res.status(404).json({ error: 'User not found for this token' }); } console.error('Fluid API error:', error.message); res.status(500).json({ error: 'Authentication error' }); } } // Widget data endpoint app.get('/api/widget-data', validateFluidToken, async (req, res) => { const externalId = req.query.external_id || req.fluidUser.externalId; const companyId = req.headers['x-company-id'] || req.fluidUser.companyId; try { // Use external_id as the primary identifier for your business logic const widgetData = await getWidgetDataFromDatabase(externalId, companyId); res.json(widgetData); } catch (error) { console.error('Error fetching widget data:', error); res.status(500).json({ error: 'Failed to fetch data' }); } }); async function getWidgetDataFromDatabase(externalId, companyId) { // This would connect to your existing database/systems // Use external_id as customer_id or rep_id in your system // Example query to your database: // const userData = await db.query( // 'SELECT * FROM users WHERE external_id = ? AND company_id = ?', // [externalId, companyId] // ); return { userName: 'John Doe', personalVolume: 5420.50, teamVolume: 45230.75, rank: 'Gold', newEnrollments: 3, monthlyGoal: 10000, goalProgress: 54.2 }; } // CORS configuration (if needed) app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Company-Id'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); next(); }); app.listen(3000, () => { console.log('Widget backend running on port 3000'); });
Ruby on Rails Backend Example
class Api::WidgetController < ApplicationController before_action :validate_fluid_token def widget_data external_id = params[:external_id] || @fluid_user[:external_id] company_id = request.headers['X-Company-Id'] || @fluid_user[:company_id] # Use external_id for business operations widget_data = get_widget_data_from_database(external_id, company_id) render json: widget_data rescue StandardError => e Rails.logger.error "Error fetching widget data: #{e.message}" render json: { error: 'Failed to fetch data' }, status: :internal_server_error end private def validate_fluid_token auth_token = request.headers['Authorization']&.gsub(/^Bearer /, '') return render json: { error: 'No auth token provided' }, status: :unauthorized if auth_token.blank? begin response = HTTParty.get( "#{ENV['FLUID_API_URL']}/api/v2025-06/users/#{auth_token}", headers: { 'Authorization' => "Bearer #{auth_token}", 'Content-Type' => 'application/json' } ) if response.success? fluid_user = response.parsed_response['user_company'] || response.parsed_response if fluid_user['external_id'].blank? return render json: { error: 'User missing external_id - cannot proceed' }, status: :bad_request end @fluid_user = { id: fluid_user['id'], external_id: fluid_user['external_id'], company_id: fluid_user['company_id'], email: fluid_user['email'], username: fluid_user['username'], roles: fluid_user['roles'] } else render json: { error: 'Invalid or expired token' }, status: :unauthorized end rescue StandardError => e Rails.logger.error "Fluid API error: #{e.message}" render json: { error: 'Authentication error' }, status: :internal_server_error end end def get_widget_data_from_database(external_id, company_id) # Connect to your existing database/systems using external_id { userName: 'John Doe', personalVolume: 5420.50, teamVolume: 45230.75, rank: 'Gold', newEnrollments: 3, monthlyGoal: 10000, goalProgress: 54.2 } end end
Step 4: Authentication Flow Details
How Fluid Generates Auth Tokens
Based on the codebase analysis, here's how authentication works:
- Token Generation:
UserCompany#generate_auth_tokencreates anAuthTokenrecord - Token Structure: Auth tokens are stored in the
auth_tokenstable, linked touser_companies - Token Validation: Fluid validates tokens through
UserCompany.find_with_active_auth_token(token) - Mobile Widget Integration: The mobile app automatically appends the auth token to widget URLs
Token Validation Process
# In Fluid's codebase (CommonEntityConcern) def handle_user_company_token(token) user_company = UserCompany.active.find_by(token: token) || UserCompany.active.find_with_active_auth_token(token) return unless user_company # Set current entities for the request assign_current_user(user_company.user) assign_current_company(user_company.company) assign_current_affiliate(user_company) user_company end
Step 5: Common Backend Endpoints
These endpoints should be created in YOUR backend system (not Fluid's):
// KPI Dashboard Data GET /api/widgets/kpi-dashboard?external_id={external_id} Headers: Authorization: Bearer {token} Response: { personalVolume: 5420.50, teamVolume: 45230.75, monthlyGoal: 10000, goalProgress: 54.2, rank: "Gold", nextRankProgress: 75 } // Team Performance Data GET /api/widgets/team-performance?external_id={external_id} Headers: Authorization: Bearer {token} Response: { directTeamCount: 12, totalTeamSize: 156, activeMembers: 89, newMembersThisMonth: 5, topPerformers: [ { name: "Jane Doe", volume: 12450.00, rank: "Silver" }, { name: "Bob Smith", volume: 8930.50, rank: "Bronze" } ] } // Bonus Tracking Information GET /api/widgets/bonus-tracker?external_id={external_id} Headers: Authorization: Bearer {token} Response: { currentBonuses: [ { name: "Fast Start Bonus", amount: 500, qualified: true }, { name: "Leadership Bonus", amount: 1200, qualified: false } ], projectedEarnings: 2340.00, qualifiedBonuses: 3, pendingQualifications: 2 }
Step 6: Mobile App Integration
Widget URL Format
https://your-backend.com/widgets/{widget-name}
Note: The auth_token is appended by Fluid automatically when the widget is loaded in the mobile app.
Mobile Widget Configuration
Based on the codebase, widgets are configured through the MobileWidget model:
# Example mobile widget configuration mobile_widget = MobileWidget.create!( company: company, name: "KPI Dashboard", embed_url: "https://your-backend.com/widgets/kpi-dashboard", active: true )
The mobile app will:
- Fetch widget configuration from Fluid via
/api/mobile_widgets/:id - Automatically append authentication token to the embed URL
- Load widget in a WebView component
- Handle navigation and refresh events
- Listen for height adjustment messages from the widget via
ReactNativeWebView.postMessage()
Dynamic Height Communication
The mobile app's WebView listens for messages from embedded widgets to enable dynamic height adjustments:
// Mobile app message handler interface EmbedMessage { type: 'HEIGHT_CHANGED'; height: number; } const onMessage = (event: WebViewMessageEvent) => { try { const data = JSON.parse(event.nativeEvent.data) as EmbedMessage; if (!data || data.type !== 'HEIGHT_CHANGED' || data.height === undefined) { throw new Error('Invalid message'); } // Clamp height between MIN and MAX values (100-1000px) const clampedHeight = Math.max(MIN_WIDGET_HEIGHT, Math.min(MAX_WIDGET_HEIGHT, data.height)); // Animate height change with 300ms duration widgetHeight.value = withTiming(clampedHeight, { duration: 300 }); } catch { throw new Error('Invalid message'); } };
Key Points:
- Messages must be valid JSON with
type: 'HEIGHT_CHANGED'and a numericheightproperty - Height values are automatically clamped between 100px and 1000px
- Height changes are smoothly animated over 300ms
- Invalid messages are caught and logged without breaking the widget
Step 7: Common Widget Types
1. KPI Dashboard Widget
class KPIDashboard extends FluidWidget { render(data, fluidUser) { const container = document.getElementById('widget-container'); container.innerHTML = ` <div class="kpi-dashboard"> <div class="user-header"> <h2>${fluidUser.username}</h2> <p>Customer ID: ${fluidUser.externalId}</p> </div> <div class="kpi-grid"> <div class="kpi-card"> <div class="kpi-value">$${data.personalVolume.toLocaleString()}</div> <div class="kpi-label">Personal Volume</div> <div class="kpi-progress"> <div class="progress-bar" style="width: ${data.personalProgress}%"></div> </div> </div> <div class="kpi-card"> <div class="kpi-value">$${data.teamVolume.toLocaleString()}</div> <div class="kpi-label">Team Volume</div> </div> <div class="kpi-card"> <div class="kpi-value">${data.newEnrollments}</div> <div class="kpi-label">New Enrollments</div> </div> <div class="kpi-card"> <div class="kpi-value">${data.rank}</div> <div class="kpi-label">Current Rank</div> </div> </div> </div> `; // Dynamically adjust height to fit content requestAnimationFrame(() => { this.autoAdjustHeight(); }); } }
2. Team Performance Widget
class TeamPerformance extends FluidWidget { async fetchWidgetData(fluidUser) { const response = await fetch( `${this.backendApiUrl}/widgets/team-performance?external_id=${fluidUser.externalId}`, { headers: { 'Authorization': `Bearer ${this.authToken}`, 'X-Company-Id': fluidUser.companyId } } ); if (!response.ok) { throw new Error('Failed to fetch team data'); } return await response.json(); } render(data, fluidUser) { const container = document.getElementById('widget-container'); const teamHtml = data.topPerformers.map(member => ` <div class="team-member"> <div class="member-info"> <span class="member-name">${member.name}</span> <span class="member-rank">${member.rank}</span> </div> <span class="member-volume">$${member.volume.toLocaleString()}</span> </div> `).join(''); container.innerHTML = ` <div class="team-widget"> <div class="team-summary"> <h3>Team Overview</h3> <div class="stats-grid"> <div class="stat"> <span class="stat-value">${data.directTeamCount}</span> <span class="stat-label">Direct Team</span> </div> <div class="stat"> <span class="stat-value">${data.totalTeamSize}</span> <span class="stat-label">Total Team</span> </div> <div class="stat"> <span class="stat-value">${data.activeMembers}</span> <span class="stat-label">Active</span> </div> </div> </div> <div class="top-performers"> <h3>Top Performers</h3> ${teamHtml} </div> </div> `; // Adjust height based on number of team members displayed requestAnimationFrame(() => { this.autoAdjustHeight(); }); } }
Best Practices
Authentication & Security
- Always validate tokens server-side: Never trust client-side validation alone
- Use HTTPS everywhere: All communications must be encrypted
- Handle token expiration: Gracefully handle expired tokens with clear user feedback
- Validate user fields: Always check for required fields like
external_id - Use external_id consistently: This should be your primary user identifier for business logic
Performance
- Implement caching: Cache frequently accessed data appropriately
- Lazy loading: Load data progressively for better perceived performance
- Batch API calls: Combine multiple requests where possible
- Optimize payloads: Keep data transfers minimal for mobile networks
- Error retry logic: Implement smart retry for failed requests
User Experience
- Responsive design: Test on all device sizes
- Loading states: Always show loading indicators
- Error messages: Provide clear, actionable error messages
- Offline handling: Consider offline scenarios for mobile users
- Touch-friendly: Ensure all interactive elements are easily tappable
Dynamic Height Management
- Check WebView availability: Always check for
window.ReactNativeWebViewbefore posting messages - Use requestAnimationFrame: Ensure DOM is fully rendered before measuring content height
- Respect height constraints: Stay within 100-1000px range; values outside are clamped
- Debounce rapid changes: Avoid excessive height change requests during animations or rapid updates
- Consider initial height: Set a reasonable initial height in widget configuration while content loads
- Test height transitions: Verify smooth animations when content changes dynamically
- Graceful degradation: Widget should work even if height adjustment fails (e.g., when testing in desktop browser)
Development Workflow
Local Development
- Use ngrok or similar for testing with real tokens
- Mock Fluid API responses for faster development
Testing
- Test with real Fluid tokens
- Verify external_id mapping
- Test token expiration scenarios
- Test with users missing required fields
Deployment
- Ensure all endpoints use HTTPS
- Set up proper logging and monitoring
- Configure CORS if needed
- Document API endpoints for mobile team
Configuration in Fluid
Widget Registration
Register widgets in Fluid through the admin interface or programmatically:
# Create a mobile widget in Fluid mobile_widget = MobileWidget.create!( company: company, name: "KPI Dashboard", description: "Real-time performance metrics", embed_url: "https://your-backend.com/widgets/kpi-dashboard", height: 400, # Height in pixels (100-1000) cover_image_url: "https://example.com/widget-cover.jpg", page_scope: { home: true, explore: false }, active: true )
Mobile Widget Management API
Fluid provides comprehensive API endpoints for managing mobile widgets including resizing and configuration updates.
Available Endpoints
1. List Mobile Widgets
GET /api/company/mobile_widgets
Authorization: Bearer {company_admin_token}
2. Get Mobile Widget Details
GET /api/company/mobile_widgets/{widget_id}
Authorization: Bearer {company_admin_token}
3. Create Mobile Widget
POST /api/company/mobile_widgets Authorization: Bearer {company_admin_token} Content-Type: application/json { "mobile_widget": { "name": "KPI Dashboard", "embed_url": "https://your-backend.com/widgets/kpi-dashboard", "height": 400, "cover_image_url": "https://example.com/cover.jpg", "page_scope": { "home": true, "explore": false }, "droplet_installation_id": null } }
4. Update Mobile Widget (Including Resizing)
PUT /api/company/mobile_widgets/{widget_id}
Authorization: Bearer {company_admin_token}
Content-Type: application/json
{
"mobile_widget": {
"name": "Updated Widget Name",
"embed_url": "https://your-backend.com/widgets/updated-url",
"height": 600, // Resize widget (100-1000 pixels)
"cover_image_url": "https://example.com/new-cover.jpg",
"page_scope": {
"home": true,
"explore": true
}
}
}
5. Delete Mobile Widget
DELETE /api/company/mobile_widgets/{widget_id}
Authorization: Bearer {company_admin_token}
Widget Properties
Based on the codebase analysis, mobile widgets support the following configurable properties:
Required Properties (for creation)
name(string): Display name for the widgetembed_url(string): URL of your widget applicationheight(integer): Widget height in pixels (100-1000)cover_image_url(string): Preview image URL for the widget
Optional Properties
page_scope(object): Controls where the widget appearshome(boolean): Show on home pageexplore(boolean): Show on explore page
droplet_installation_id(integer, nullable): Link to droplet installation if applicable
Widget Sizing and Dimensions
Mobile widgets have flexible sizing options:
Height Constraints
- Minimum height: 100 pixels
- Maximum height: 1000 pixels
- Default behavior: Widget width is responsive to mobile screen size
- Height control: Explicitly set via the
heightproperty
Responsive Behavior
The mobile app handles widget display with these characteristics:
- Width: Automatically adapts to mobile screen width
- Height: Fixed based on the
heightproperty you specify - Overflow: Scrollable if content exceeds specified height
- Aspect ratio: Maintained within the specified height constraint
Dynamic Widget Resizing
You can programmatically resize widgets using the update API:
// Example: Resize a widget based on content async function resizeWidget(widgetId, newHeight) { const response = await fetch(`/api/company/mobile_widgets/${widgetId}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ mobile_widget: { height: Math.max(100, Math.min(1000, newHeight)) // Ensure within bounds } }) }); if (response.ok) { console.log('Widget resized successfully'); } }
Page Scope Configuration
Control where your widgets appear in the mobile app:
// Show widget on both home and explore pages { "page_scope": { "home": true, "explore": true } } // Show widget only on home page { "page_scope": { "home": true, "explore": false } } // Show widget only on explore page { "page_scope": { "home": false, "explore": true } }
Troubleshooting
Common Issues and Solutions
Authentication Failures
// Check token format console.log('Token:', authToken?.substring(0, 10) + '...'); // Verify API endpoint console.log('Calling:', `${FLUID_API_URL}/api/me`); // Log response status console.log('Response status:', response.status);
Missing External ID
// Always validate external_id before business operations if (!fluidUser.externalId) { console.error('User missing external_id'); // Handle appropriately - show error or use fallback }
CORS Issues
// Backend CORS configuration app.use(cors({ origin: '*', // Configure appropriately for production credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Company-Id'] }));
Performance Issues
// Implement caching const cache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes async function getCachedData(key, fetcher) { const cached = cache.get(key); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.data; } const data = await fetcher(); cache.set(key, { data, timestamp: Date.now() }); return data; }
Dynamic Height Not Working
// Debug helper: Check if running in mobile WebView function checkWebViewEnvironment() { const isWebView = typeof window.ReactNativeWebView !== 'undefined'; console.log('Running in mobile WebView:', isWebView); return isWebView; } // Debug helper: Log height change attempts function debugHeightChange(height) { console.log('Attempting height change:', { requested: height, clamped: Math.max(100, Math.min(1000, height)), contentHeight: document.body.scrollHeight, isWebView: typeof window.ReactNativeWebView !== 'undefined' }); } // Test height adjustment function testHeightAdjustment() { if (!checkWebViewEnvironment()) { console.warn('Not in mobile WebView - height changes will not work'); return; } const testHeight = 500; debugHeightChange(testHeight); try { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'HEIGHT_CHANGED', height: testHeight })); console.log('Height change message posted successfully'); } catch (error) { console.error('Failed to post height change:', error); } }
Common Issues:
- Not in WebView: Height changes only work in the mobile app's React Native WebView
- Timing Issues: DOM not fully rendered - use
requestAnimationFrame()orsetTimeout() - Invalid Message Format: Ensure JSON is valid with correct
typeandheightproperties - Height Out of Range: Values are clamped to 100-1000px, check your calculations
Deployment Checklist
- All URLs use HTTPS
- Fluid API endpoint correctly configured
- Authentication flow thoroughly tested
- External ID validation implemented
- Error handling covers all scenarios
- Mobile-responsive design verified
- Performance tested with production data volumes
- CORS properly configured (if needed)
- Logging and monitoring configured
- API documentation provided to mobile team
- Widget registered in Fluid admin
- Tested in actual mobile app environment
- Fallback behavior for missing user fields
- Token expiration handling implemented
- Security review completed
- Dynamic height adjustment tested in mobile app
- Height constraints respected (100-1000px)
- Height adjustments work smoothly during content changes
- Widget degrades gracefully when not in mobile WebView
Support and Resources
- Fluid API Documentation: Contact Fluid support for API documentation
- Mobile Integration: Coordinate with mobile app development team
- Widget Examples: Reference implementations in shared repository
- Support: Contact Fluid support for platform-specific questions
Key Implementation Notes
- Authentication Flow: Fluid automatically appends auth tokens to widget URLs when loaded in the mobile app
- Token Validation: Use standard HTTP Bearer token authentication to validate with Fluid's API
- External ID Usage: The
external_idfield inUserCompanyis your primary business identifier - Mobile Widget Model: Widgets are configured through Fluid's
MobileWidgetmodel - Auth Token Generation: Tokens are generated via
UserCompany#generate_auth_tokenand stored inauth_tokenstable - Dynamic Height Adjustment: Widgets can dynamically resize by posting
{ type: 'HEIGHT_CHANGED', height: number }messages viaReactNativeWebView.postMessage(), with heights clamped between 100-1000px and animated over 300ms
This guide provides a complete foundation for creating mobile widgets on the Fluid platform, validated against the actual codebase implementation.