Skip to main content

📱 Native Widgets

ShipNative includes preconfigured native widgets for both iOS and Android platforms, with seamless Supabase integration for real-time data display.

Overview

Widgets allow users to view key information from your app directly on their home screen without opening the app. ShipNative provides:
  • iOS Widgets - Built with SwiftUI, supporting all widget sizes
  • Android Widgets - Built with Kotlin, supporting all widget sizes
  • Supabase Integration - Automatic data fetching with authentication
  • Easy Styling - Theme-aware widgets that match your app design
  • Feature Flag - Easily enable/disable widgets via configuration

Quick Start

1. Enable Widgets

Widgets are disabled by default. To enable them, add to your .env file:
EXPO_PUBLIC_ENABLE_WIDGETS=true
Or set it in your environment:
export EXPO_PUBLIC_ENABLE_WIDGETS=true

2. Install Dependencies

The widget package is already included in package.json. If you need to reinstall:
cd apps/app
yarn install

3. Run Prebuild

Widgets require native code, so you need to run prebuild:
yarn prebuild:clean
This will generate the native iOS and Android projects with widget extensions.

4. Build and Run

# iOS
yarn ios

# Android
yarn android

Widget Structure

Widgets are located in app/widgets/:
app/widgets/
├── ios/
│   └── ExampleWidget.swift      # iOS SwiftUI widget
├── android/
│   ├── ExampleWidget.kt         # Android Kotlin widget
│   ├── widget_info.xml          # Widget configuration
│   └── example_widget.xml       # Widget layout
├── README.md                     # Widget documentation
└── SECURITY.md                   # Security best practices

Using Widgets in Your App

Fetching Widget Data

Use the useWidgetData hook to fetch data for widgets:
import { useWidgetData } from "@/hooks/useWidgetData"

function WidgetSettingsScreen() {
  const { data, loading, error, refetch } = useWidgetData({
    table: "profiles",
    select: "id, first_name, avatar_url",
    filters: { user_id: currentUserId },
    limit: 1,
    requireAuth: true,
    refreshInterval: 15 * 60 * 1000, // 15 minutes
  })

  if (loading) return <Spinner />
  if (error) return <Text>Error: {error.message}</Text>

  return (
    <View>
      <Text>Welcome {data?.first_name}</Text>
      <Button onPress={refetch} title="Refresh" />
    </View>
  )
}

Widget Service

The widget service handles secure data fetching with caching:
import { fetchWidgetData, getWidgetConfig } from "@/services/widgets"

// Fetch data
const { data, error } = await fetchWidgetData({
  table: "posts",
  select: "id, title, created_at",
  filters: { published: true },
  limit: 5,
  orderBy: { column: "created_at", ascending: false },
  requireAuth: false, // Public data
})

// Get widget configuration
const config = getWidgetConfig()
// { supabaseUrl, supabaseKey, isMock }

Supabase Integration

Sharing Session Tokens

Widgets need access to Supabase session tokens to fetch authenticated data. The app automatically shares tokens via:
  • iOS: App Groups (UserDefaults)
  • Android: SharedPreferences

Setting Up App Groups (iOS)

  1. In Xcode, select your app target
  2. Go to “Signing & Capabilities”
  3. Add “App Groups” capability
  4. Create a new group: group.com.yourcompany.yourapp
  5. Update ExampleWidget.swift with your App Group identifier

Data Fetching in Widgets

Widgets fetch data directly from Supabase using the REST API: iOS (Swift):
// Get shared UserDefaults
let userDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")

// Get Supabase config
let supabaseUrl = userDefaults?.string(forKey: "supabase_url")
let supabaseKey = userDefaults?.string(forKey: "supabase_key")
let sessionToken = userDefaults?.string(forKey: "supabase_session_token")

// Make HTTP request to Supabase
// GET {supabaseUrl}/rest/v1/{table}?select=*
// Headers: apikey, Authorization (if token available)
Android (Kotlin):
// Get shared preferences
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)

// Get Supabase config
val supabaseUrl = prefs.getString("supabase_url", null)
val supabaseKey = prefs.getString("supabase_key", null)
val sessionToken = prefs.getString("supabase_session_token", null)

// Make HTTP request to Supabase
// Similar to iOS implementation

Styling Widgets

iOS Widget Styling

Widgets use SwiftUI with theme colors:
// Theme colors
private let primaryColor = Color(red: 0.2, green: 0.4, blue: 0.8)
private let backgroundColor = Color(red: 0.95, green: 0.95, blue: 0.97)
private let textColor = Color(red: 0.1, green: 0.1, blue: 0.1)

// Use in views
Text("Title")
    .font(.headline)
    .foregroundColor(textColor)

Android Widget Styling

Widgets use XML layouts with theme colors:
<TextView
    android:text="Title"
    android:textSize="16sp"
    android:textStyle="bold"
    android:textColor="#1a1a1a" />
Update colors in example_widget.xml to match your app theme.

Security Best Practices

1. Row Level Security (RLS)

Enable RLS policies in Supabase for widget-accessible tables:
-- Example: Allow widgets to read public posts
CREATE POLICY "Widgets can read public posts"
ON posts FOR SELECT
USING (published = true);

2. Token Management

  • Store session tokens securely in App Groups/SharedPreferences
  • Tokens are automatically refreshed by the main app
  • Widgets use read-only access to tokens

3. Data Validation

Always validate data before displaying in widgets:
import { validateWidgetData } from "@/services/widgets"

const isValid = validateWidgetData(data, (d) => {
  return d && typeof d.title === "string" && d.title.length > 0
})

4. Rate Limiting

Widget updates are rate-limited to prevent excessive API calls:
  • Minimum 5 minutes between updates
  • 15-minute cache duration
  • Maximum 10 cached items

Creating Custom Widgets

iOS Widget

  1. Create a new Swift file in app/widgets/ios/
  2. Implement TimelineProvider protocol
  3. Create SwiftUI view for widget content
  4. Register widget in ExampleWidget.swift
Example structure:
struct MyWidgetProvider: TimelineProvider {
    func placeholder(in context: Context) -> MyEntry {
        // Return placeholder data
    }
    
    func getSnapshot(in context: Context, completion: @escaping (MyEntry) -> Void) {
        // Return snapshot data
    }
    
    func getTimeline(in context: Context, completion: @escaping (Timeline<MyEntry>) -> Void) {
        // Fetch data and create timeline
    }
}

Android Widget

  1. Create a new Kotlin file in app/widgets/android/
  2. Extend AppWidgetProvider
  3. Create XML layout in res/layout/
  4. Register widget in widget_info.xml
Example structure:
class MyWidgetProvider : AppWidgetProvider() {
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        // Update widget
    }
}

Troubleshooting

Widget Not Showing

  1. Check feature flag: Ensure EXPO_PUBLIC_ENABLE_WIDGETS=true
  2. Run prebuild: Widgets require native code generation
  3. Check logs: Look for widget-related errors in Xcode/Android Studio

Data Not Loading

  1. Check Supabase config: Verify URL and key are set
  2. Check session token: Ensure user is authenticated
  3. Check RLS policies: Widgets need appropriate permissions
  4. Check network: Widgets need internet access

Build Errors

  1. iOS: Check App Group identifier matches in app and widget
  2. Android: Check package name matches in widget files
  3. Missing files: Ensure all widget files are in correct locations

Examples

Displaying User Profile

// iOS
struct ProfileWidget: View {
    var entry: ProfileEntry
    
    var body: some View {
        VStack {
            Text(entry.userName)
            Text(entry.email)
        }
    }
}

Displaying Recent Posts

// Android
fun updatePostsWidget(context: Context, posts: List<Post>) {
    val views = RemoteViews(context.packageName, R.layout.posts_widget)
    views.setTextViewText(R.id.post_count, "${posts.size} posts")
    // Update widget
}

API Reference

useWidgetData Hook

useWidgetData<T>(options: UseWidgetDataOptions<T>): UseWidgetDataReturn<T>
Options:
  • table: string - Supabase table name
  • select?: string - Columns to select (default: ”*”)
  • filters?: Record<string, any> - Filter conditions
  • limit?: number - Maximum rows (default: 10)
  • orderBy?: { column: string; ascending?: boolean } - Sort order
  • requireAuth?: boolean - Require authentication (default: false)
  • cacheKey?: string - Custom cache key
  • refreshInterval?: number - Auto-refresh interval in ms
  • enabled?: boolean - Enable/disable hook (default: true)
Returns:
  • data: T | null - Fetched data
  • loading: boolean - Loading state
  • error: Error | null - Error if any
  • refetch: () => Promise<void> - Manual refresh
  • clearCache: () => void - Clear cache
  • config: WidgetConfig - Widget configuration

Widget Service

fetchWidgetData<T>(options: FetchOptions): Promise<{ data: T | null; error: Error | null }>
getWidgetConfig(): WidgetConfig
clearWidgetCache(key?: string): void
validateWidgetData<T>(data: T | null, validator?: (data: T) => boolean): boolean

Next Steps