Merge pull request #244 from cogwheel0/widget-quick-access-actions

feat(widget): Add home screen widget with quick access actions
This commit is contained in:
cogwheel
2025-12-07 14:56:35 +08:00
committed by GitHub
59 changed files with 1950 additions and 14 deletions

View File

@@ -119,6 +119,19 @@
android:exported="false"
android:foregroundServiceType="dataSync|microphone"/>
<!-- Home Screen Widget -->
<receiver
android:name=".ConduitWidgetProvider"
android:exported="true"
android:label="@string/widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/conduit_widget_info" />
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@@ -0,0 +1,107 @@
package app.cogwheel.conduit
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.net.Uri
import android.widget.RemoteViews
import es.antonborri.home_widget.HomeWidgetLaunchIntent
/**
* Home screen widget provider for Conduit.
*
* Provides quick actions:
* - New Chat: Start a fresh conversation
* - Mic: Start voice input
* - Camera: Take a photo and attach to chat
* - Photos: Pick from gallery and attach to chat
* - Clipboard: Paste clipboard content as prompt
*/
class ConduitWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
override fun onEnabled(context: Context) {
// Called when the first widget is created
}
override fun onDisabled(context: Context) {
// Called when the last widget is removed
}
companion object {
private const val ACTION_NEW_CHAT = "new_chat"
private const val ACTION_MIC = "mic"
private const val ACTION_CAMERA = "camera"
private const val ACTION_PHOTOS = "photos"
private const val ACTION_CLIPBOARD = "clipboard"
private fun updateAppWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.conduit_widget)
// Set up click handlers using home_widget's launch intent
views.setOnClickPendingIntent(
R.id.widget_container,
HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("homewidget://$ACTION_NEW_CHAT")
)
)
views.setOnClickPendingIntent(
R.id.btn_new_chat,
HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("homewidget://$ACTION_NEW_CHAT")
)
)
views.setOnClickPendingIntent(
R.id.btn_mic,
HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("homewidget://$ACTION_MIC")
)
)
views.setOnClickPendingIntent(
R.id.btn_camera,
HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("homewidget://$ACTION_CAMERA")
)
)
views.setOnClickPendingIntent(
R.id.btn_photos,
HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("homewidget://$ACTION_PHOTOS")
)
)
views.setOnClickPendingIntent(
R.id.btn_clipboard,
HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("homewidget://$ACTION_CLIPBOARD")
)
)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container">
<path
android:fillColor="@android:color/white"
android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0" />
<path
android:fillColor="@android:color/white"
android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container">
<path
android:fillColor="@android:color/white"
android:pathData="M19,2h-4.18C14.4,0.84 13.3,0 12,0c-1.3,0 -2.4,0.84 -2.82,2H5c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM12,2c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM14,18H7v-2h7v2zM17,14H7v-2h10v2zM17,10H7V8h10v2z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Design Mic Icon - Material You -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mic icon with accent color tint - Material You -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_mic_icon">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container">
<path
android:fillColor="@android:color/white"
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Graphic equalizer icon for voice - Material You -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container">
<path
android:fillColor="@android:color/white"
android:pathData="M7,18h2V6H7v12zM11,22h2V2h-2v20zM3,14h2v-4H3v4zM15,18h2V6h-2v12zM19,10v4h2v-4h-2z" />
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Widget background - Material You dynamic surface color -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface" />
<corners android:radius="@dimen/widget_corner_radius" />
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Rounded button - Material You -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#20FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/widget_secondary_container" />
<corners android:radius="16dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mic button inside pill - Material You lighter accent -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#20FFFFFF">
<item>
<shape android:shape="oval">
<solid android:color="@color/widget_mic_background" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Pill-shaped primary button - Material You dynamic color (darker accent) -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#40FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/widget_primary_dark" />
<corners android:radius="24dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Primary button - Material You dynamic primary color -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#40FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/widget_primary" />
<corners android:radius="@dimen/widget_button_corner_radius" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Secondary button - Material You dynamic secondary container color -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#20000000">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/widget_secondary_container" />
<corners android:radius="@dimen/widget_secondary_corner_radius" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Design Hub Icon (Filled) -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M240,920q-50,0 -85,-35t-35,-85q0,-50 35,-85t85,-35q14,0 26,3t23,8l57,-71q-28,-31 -39,-70t-5,-78l-81,-27q-17,25 -43,40t-58,15q-50,0 -85,-35T0,380q0,-50 35,-85t85,-35q50,0 85,35t35,85v8l81,28q20,-36 53.5,-61t75.5,-32v-87q-39,-11 -64.5,-42.5T360,120q0,-50 35,-85t85,-35q50,0 85,35t35,85q0,42 -26,73.5T510,236v87q42,7 75.5,32t53.5,61l81,-28v-8q0,-50 35,-85t85,-35q50,0 85,35t35,85q0,50 -35,85t-85,35q-32,0 -58.5,-15T739,445l-81,27q6,39 -5,77.5T614,620l57,70q11,-5 23,-7.5t26,-2.5q50,0 85,35t35,85q0,50 -35,85t-85,35q-50,0 -85,-35t-35,-85q0,-20 6.5,-38.5T624,728l-57,-71q-41,23 -87.5,23T392,657l-56,71q11,15 17.5,33.5T360,800q0,50 -35,85t-85,35Z" />
</vector>

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2l1.5,5.5L19,9l-5.5,1.5L12,16l-1.5,-5.5L5,9l5.5,-1.5L12,2z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M19,15l0.94,2.06L22,18l-2.06,0.94L19,21l-0.94,-2.06L16,18l2.06,-0.94L19,15z"/>
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container_fallback">
<path
android:fillColor="@android:color/white"
android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0" />
<path
android:fillColor="@android:color/white"
android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container_fallback">
<path
android:fillColor="@android:color/white"
android:pathData="M19,2h-4.18C14.4,0.84 13.3,0 12,0c-1.3,0 -2.4,0.84 -2.82,2H5c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM12,2c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM14,18H7v-2h7v2zM17,14H7v-2h10v2zM17,10H7V8h10v2z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Design Mic Icon -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container_fallback">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mic icon with accent color tint -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_mic_icon_fallback">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container_fallback">
<path
android:fillColor="@android:color/white"
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Graphic equalizer icon for voice -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_on_secondary_container_fallback">
<path
android:fillColor="@android:color/white"
android:pathData="M7,18h2V6H7v12zM11,22h2V2h-2v20zM3,14h2v-4H3v4zM15,18h2V6h-2v12zM19,10v4h2v-4h-2z" />
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Widget background - Material 3 surface with large corner radius -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface_fallback" />
<corners android:radius="@dimen/widget_corner_radius" />
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Rounded button - ChatGPT style -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#20FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/widget_secondary_container_fallback" />
<corners android:radius="16dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mic button inside pill - lighter accent background -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#20FFFFFF">
<item>
<shape android:shape="oval">
<solid android:color="@color/widget_mic_background_fallback" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Pill-shaped primary button - Material 3 style with darker accent -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#40FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/widget_primary_dark_fallback" />
<corners android:radius="24dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Primary button - Material 3 filled button style -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#40FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/widget_primary_fallback" />
<corners android:radius="@dimen/widget_button_corner_radius" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Secondary button - Material 3 tonal button style -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#20000000">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/widget_secondary_container_fallback" />
<corners android:radius="@dimen/widget_secondary_corner_radius" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Widget preview shown in the widget picker - Material 3 style -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Background -->
<item>
<shape android:shape="rectangle">
<solid android:color="@color/widget_surface_fallback" />
<corners android:radius="@dimen/widget_corner_radius" />
</shape>
</item>
<!-- Primary button preview -->
<item
android:left="16dp"
android:top="16dp"
android:right="16dp"
android:bottom="72dp">
<shape android:shape="rectangle">
<solid android:color="@color/widget_primary_fallback" />
<corners android:radius="@dimen/widget_button_corner_radius" />
</shape>
</item>
<!-- Secondary buttons preview -->
<item
android:left="16dp"
android:top="80dp"
android:right="176dp"
android:bottom="16dp">
<shape android:shape="rectangle">
<solid android:color="@color/widget_secondary_container_fallback" />
<corners android:radius="@dimen/widget_secondary_corner_radius" />
</shape>
</item>
<item
android:left="96dp"
android:top="80dp"
android:right="96dp"
android:bottom="16dp">
<shape android:shape="rectangle">
<solid android:color="@color/widget_secondary_container_fallback" />
<corners android:radius="@dimen/widget_secondary_corner_radius" />
</shape>
</item>
<item
android:left="176dp"
android:top="80dp"
android:right="16dp"
android:bottom="16dp">
<shape android:shape="rectangle">
<solid android:color="@color/widget_secondary_container_fallback" />
<corners android:radius="@dimen/widget_secondary_corner_radius" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- ChatGPT-style widget design -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:background="@drawable/widget_background"
android:clipToOutline="true">
<!-- Main "Ask Conduit" Pill -->
<LinearLayout
android:id="@+id/btn_new_chat"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="@drawable/widget_button_pill"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="20dp"
android:layout_marginBottom="12dp">
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@drawable/ic_hub"
android:importantForAccessibility="no"
android:layout_marginEnd="12dp"
tools:ignore="UseAppTint" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/widget_ask_conduit"
android:textColor="@color/widget_on_primary"
android:textSize="18sp"
android:fontFamily="sans-serif-medium" />
</LinearLayout>
<!-- 4 Circular Icon Buttons Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:gravity="center"
android:baselineAligned="false">
<!-- Camera Button -->
<FrameLayout
android:id="@+id/btn_camera"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_marginEnd="6dp"
android:background="@drawable/widget_button_circle">
<ImageView
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="center"
android:src="@drawable/ic_widget_camera"
android:importantForAccessibility="no"
tools:ignore="UseAppTint" />
</FrameLayout>
<!-- Photos Button -->
<FrameLayout
android:id="@+id/btn_photos"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_marginHorizontal="6dp"
android:background="@drawable/widget_button_circle">
<ImageView
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="center"
android:src="@drawable/ic_widget_photos"
android:importantForAccessibility="no"
tools:ignore="UseAppTint" />
</FrameLayout>
<!-- Voice/Mic Button -->
<FrameLayout
android:id="@+id/btn_mic"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_marginHorizontal="6dp"
android:background="@drawable/widget_button_circle">
<ImageView
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="center"
android:src="@drawable/ic_widget_waveform"
android:importantForAccessibility="no"
tools:ignore="UseAppTint" />
</FrameLayout>
<!-- Clipboard Button -->
<FrameLayout
android:id="@+id/btn_clipboard"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:background="@drawable/widget_button_circle">
<ImageView
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="center"
android:src="@drawable/ic_widget_clipboard"
android:importantForAccessibility="no"
tools:ignore="UseAppTint" />
</FrameLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Material 3 Widget Colors (Dark Mode) -->
<!-- Primary colors for main button -->
<color name="widget_primary">@android:color/system_accent1_200</color>
<color name="widget_primary_dark">@android:color/system_accent1_400</color>
<color name="widget_on_primary">@android:color/system_accent1_800</color>
<!-- Mic button colors (lighter accent inside pill) -->
<color name="widget_mic_background">@android:color/system_accent1_200</color>
<color name="widget_mic_icon">@android:color/system_accent1_800</color>
<!-- Secondary container colors for action buttons -->
<color name="widget_secondary_container">@android:color/system_accent2_700</color>
<color name="widget_on_secondary_container">@android:color/system_accent2_100</color>
<!-- Surface colors for widget background -->
<color name="widget_surface">@android:color/system_neutral1_900</color>
<color name="widget_surface_variant">@android:color/system_neutral2_700</color>
<!-- Fallback colors for pre-Android 12 -->
<color name="widget_primary_fallback">#D0BCFF</color>
<color name="widget_primary_dark_fallback">#9A82DB</color>
<color name="widget_mic_background_fallback">#D0BCFF</color>
<color name="widget_mic_icon_fallback">#4A3880</color>
<color name="widget_secondary_container_fallback">#4A4458</color>
<color name="widget_on_secondary_container_fallback">#E8DEF8</color>
<color name="widget_surface_fallback">#1C1B1F</color>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Widget dimensions (same for both modes) -->
<dimen name="widget_padding">12dp</dimen>
<dimen name="widget_button_spacing">6dp</dimen>
<dimen name="widget_corner_radius">24dp</dimen>
<dimen name="widget_button_corner_radius">16dp</dimen>
<dimen name="widget_secondary_corner_radius">12dp</dimen>
</resources>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Material 3 Widget Colors (Light Mode) -->
<!-- Primary colors for main button -->
<color name="widget_primary">@android:color/system_accent1_600</color>
<color name="widget_primary_dark">@android:color/system_accent1_800</color>
<color name="widget_on_primary">@android:color/white</color>
<!-- Mic button colors (lighter accent inside pill) -->
<color name="widget_mic_background">@android:color/system_accent1_100</color>
<color name="widget_mic_icon">@android:color/system_accent1_700</color>
<!-- Secondary container colors for action buttons -->
<color name="widget_secondary_container">@android:color/system_accent2_100</color>
<color name="widget_on_secondary_container">@android:color/system_accent1_700</color>
<!-- Surface colors for widget background -->
<color name="widget_surface">@android:color/system_neutral1_10</color>
<color name="widget_surface_variant">@android:color/system_neutral2_100</color>
<!-- Fallback colors for pre-Android 12 -->
<color name="widget_primary_fallback">#6750A4</color>
<color name="widget_primary_dark_fallback">#4A3880</color>
<color name="widget_mic_background_fallback">#E8DEF8</color>
<color name="widget_mic_icon_fallback">#6750A4</color>
<color name="widget_secondary_container_fallback">#E8DEF8</color>
<color name="widget_on_secondary_container_fallback">#1D192B</color>
<color name="widget_surface_fallback">#FFFBFE</color>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Widget dimensions following Material 3 guidelines -->
<dimen name="widget_padding">12dp</dimen>
<dimen name="widget_button_spacing">6dp</dimen>
<dimen name="widget_corner_radius">24dp</dimen>
<dimen name="widget_button_corner_radius">16dp</dimen>
<dimen name="widget_secondary_corner_radius">12dp</dimen>
</resources>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Conduit</string>
<!-- Widget strings -->
<string name="widget_name">Conduit</string>
<string name="widget_description">Quick access to Conduit chat with camera, photos, and clipboard shortcuts</string>
<string name="widget_ask_conduit">Ask Conduit</string>
<string name="widget_camera">Camera</string>
<string name="widget_photos">Photos</string>
<string name="widget_clipboard">Clipboard</string>
<string name="widget_mic">Voice</string>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="400dp"
android:maxResizeHeight="200dp"
android:updatePeriodMillis="0"
android:initialLayout="@layout/conduit_widget"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@drawable/widget_preview"
android:description="@string/widget_description"
android:widgetFeatures="reconfigurable" />

View File

@@ -0,0 +1,133 @@
# Android Widget Setup
The Android home screen widget is automatically included in the app build. This document describes the implementation details.
## Overview
The widget uses **Material 3 / Material You** design with dynamic colors on Android 12+ (API 31+).
## Widget Design (Native Android)
```
┌─────────────────────────────────────┐
│ ✨ Ask Conduit │ ← Primary color (dynamic)
│ │
├───────────┬───────────┬─────────────┤
│ 📷 │ 🖼️ │ 📋 │
│ Camera │ Photos │ Clipboard │ ← Secondary container
└───────────┴───────────┴─────────────┘
```
### Material You (Android 12+)
- **Primary button**: System accent color (`system_accent1_600`)
- **Secondary buttons**: Secondary container color (`system_accent2_100`)
- **Background**: Neutral surface color (`system_neutral1_10`)
- **Icons**: Tinted with `system_accent1_700`
### Fallback (Android 11 and below)
- **Primary button**: Material 3 default purple (`#6750A4`)
- **Secondary buttons**: Light purple container (`#E8DEF8`)
- **Background**: Near-white surface (`#FFFBFE`)
## Files Structure
```
android/app/src/main/
├── kotlin/.../ConduitWidgetProvider.kt # Widget logic
├── res/
│ ├── layout/
│ │ └── conduit_widget.xml # Widget layout
│ ├── drawable/
│ │ ├── widget_background.xml # Surface background
│ │ ├── widget_button_primary.xml # Primary button
│ │ ├── widget_button_secondary.xml # Secondary buttons
│ │ ├── ic_widget_camera.xml # Camera icon
│ │ ├── ic_widget_photos.xml # Photos icon
│ │ ├── ic_widget_clipboard.xml # Clipboard icon
│ │ └── widget_preview.xml # Widget picker preview
│ ├── drawable-v31/ # Material You overrides
│ │ └── (same files with dynamic colors)
│ ├── values/
│ │ ├── colors.xml # Light mode colors
│ │ ├── dimens.xml # Widget dimensions
│ │ └── strings.xml # Widget strings
│ ├── values-night/
│ │ └── colors.xml # Dark mode colors
│ └── xml/
│ └── conduit_widget_info.xml # Widget metadata
└── AndroidManifest.xml # Widget receiver registration
```
## Deep Link Handling
The widget uses `homewidget://` URL scheme:
| Action | URL |
|--------|-----|
| New Chat | `homewidget://new_chat` |
| Camera | `homewidget://camera` |
| Photos | `homewidget://photos` |
| Clipboard | `homewidget://clipboard` |
## Widget Configuration
The widget is configured in `res/xml/conduit_widget_info.xml`:
- **Min size**: 250x110dp (3x2 cells)
- **Resizable**: Horizontal and vertical
- **Category**: Home screen
- **Update period**: Never (static widget)
## Testing
1. Build and install the debug APK:
```bash
flutter build apk --debug
flutter install
```
2. Long press on home screen
3. Tap "Widgets"
4. Search for "Conduit"
5. Drag widget to home screen
## Customization
### Changing the accent color
The widget automatically picks up the system's Material You palette on Android 12+. On older versions, modify the fallback colors in `values/colors.xml`:
```xml
<color name="widget_primary_fallback">#YOUR_COLOR</color>
```
### Changing widget size
Modify `res/xml/conduit_widget_info.xml`:
```xml
<appwidget-provider
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
...
/>
```
## Troubleshooting
### Widget not appearing
- Ensure the app was installed (not just built)
- Try restarting the home launcher
- Check that `ConduitWidgetProvider` is registered in AndroidManifest.xml
### Colors not updating on theme change
- Widget colors are set at creation time
- User needs to re-add widget after theme change
- Or trigger update via `HomeWidget.updateWidget()` from Flutter

120
docs/ios-widget-setup.md Normal file
View File

@@ -0,0 +1,120 @@
# iOS Widget Extension Setup
This document describes how the ConduitWidget extension was added and how to configure it.
## Overview
The widget extension was created via Xcode and provides quick access buttons:
- **Ask Conduit** - Opens app with new chat, focuses composer
- **Camera** - Opens app, creates new chat, launches camera
- **Photos** - Opens app, creates new chat, opens photo picker
- **Clipboard** - Opens app with clipboard contents as prompt
## Files Structure
```
ios/ConduitWidget/
├── Assets.xcassets/ # Asset catalog
│ ├── AccentColor.colorset/ # Theme accent color
│ ├── AppIcon.appiconset/ # Widget icon (uses app icon)
│ └── WidgetBackground.colorset/ # Light/dark backgrounds
├── ConduitWidget.entitlements # App group for data sharing
├── ConduitWidget.swift # Main widget implementation
├── ConduitWidgetBundle.swift # Widget bundle entry point
└── Info.plist # Extension configuration
```
## App Group Configuration
Both the main app and widget share data via the app group `group.app.cogwheel.conduit`.
**Important:** Ensure both targets have this app group in their capabilities:
1. Select the **Runner** target → Signing & Capabilities → App Groups
2. Verify `group.app.cogwheel.conduit` is listed
3. Select the **ConduitWidget** target → Signing & Capabilities → App Groups
4. Verify `group.app.cogwheel.conduit` is listed
## Deep Link Handling
The widget uses `homewidget://` URL scheme to communicate with the Flutter app:
| Action | URL |
|--------|-----|
| New Chat | `homewidget://new_chat` |
| Camera | `homewidget://camera` |
| Photos | `homewidget://photos` |
| Clipboard | `homewidget://clipboard` |
These are handled by `HomeWidgetCoordinator` in the Flutter code.
## Widget Design (Native iOS)
```
┌─────────────────────────────────────┐
│ ✨ Ask Conduit │ ← System tint color
│ │
├───────────┬───────────┬─────────────┤
│ 📷 │ 🖼️ │ 📋 │
│ Camera │ Photos │ Clipboard │ ← Secondary system bg
└───────────┴───────────┴─────────────┘
```
- **Size**: Medium widget (systemMedium)
- **Primary button**: System tint color (follows app accent)
- **Secondary buttons**: `secondarySystemGroupedBackground`
- **Icons**: SF Symbols with hierarchical rendering
- **Typography**: SF Rounded font
- **Supports**: Light/dark mode, Dynamic Type
## Building
The widget extension is built automatically when you build the main app:
```bash
flutter build ios
```
Or build from Xcode:
1. Open `ios/Runner.xcworkspace`
2. Select the **Runner** scheme
3. Build (⌘B)
## Testing
1. Build and run the main app on a device/simulator
2. Go to home screen
3. Long press → tap **+** to add widgets
4. Search for "Conduit"
5. Add the medium widget
## Troubleshooting
### Widget not appearing in picker
- Ensure the widget extension builds without errors
- Check deployment target is iOS 17.0+
- Clean build folder (⇧⌘K) and rebuild
### Widget actions don't work
- Verify the `homewidget://` URL scheme is handled
- Check `HomeWidgetCoordinator` is initialized in app startup
- Ensure app group is configured on both targets
### Widget doesn't update
- The widget uses `.never` refresh policy (static content)
- Call `HomeWidget.updateWidget()` from Flutter to trigger refresh
## Adding to a Fresh Clone
If cloning the repo fresh, the widget extension should already be configured in the Xcode project. Just ensure:
1. Team/signing is set for both targets
2. App groups capability is enabled
3. Pod install has been run

View File

@@ -0,0 +1,21 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.929",
"green" : "0.227",
"red" : "0.486"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,7 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,17 @@
{
"images" : [
{
"filename" : "hub.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M240-40q-50 0-85-35t-35-85q0-50 35-85t85-35q14 0 26 3t23 8l57-71q-28-31-39-70t-5-78l-81-27q-17 25-43 40t-58 15q-50 0-85-35T0-580q0-50 35-85t85-35q50 0 85 35t35 85v8l81 28q20-36 53.5-61t75.5-32v-87q-39-11-64.5-42.5T360-840q0-50 35-85t85-35q50 0 85 35t35 85q0 42-26 73.5T510-724v87q42 7 75.5 32t53.5 61l81-28v-8q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-32 0-58.5-15T739-515l-81 27q6 39-5 77.5T614-340l57 70q11-5 23-7.5t26-2.5q50 0 85 35t35 85q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-20 6.5-38.5T624-232l-57-71q-41 23-87.5 23T392-303l-56 71q11 15 17.5 33.5T360-160q0 50-35 85t-85 35Z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 719 B

View File

@@ -0,0 +1,39 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.961",
"green" : "0.961",
"red" : "0.961"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.118",
"green" : "0.118",
"red" : "0.118"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.cogwheel.conduit</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,129 @@
//
// ConduitWidget.swift
// ConduitWidget
//
// Created by cogwheel on 07/12/25.
//
import WidgetKit
import SwiftUI
// MARK: - Timeline Entry
struct ConduitEntry: TimelineEntry {
let date: Date
}
// MARK: - Timeline Provider
struct ConduitProvider: TimelineProvider {
func placeholder(in context: Context) -> ConduitEntry {
ConduitEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (ConduitEntry) -> Void) {
let entry = ConduitEntry(date: Date())
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<ConduitEntry>) -> Void) {
let entry = ConduitEntry(date: Date())
let timeline = Timeline(entries: [entry], policy: .never)
completion(timeline)
}
}
// MARK: - Widget View
struct ConduitWidgetEntryView: View {
var entry: ConduitProvider.Entry
@Environment(\.widgetFamily) var family
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(spacing: 12) {
// Main "Ask Conduit" pill - ChatGPT style
Link(destination: URL(string: "homewidget://new_chat")!) {
HStack(spacing: 12) {
Image("HubIcon")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundStyle(.white.opacity(0.9))
Text("Ask Conduit")
.font(.system(size: 18, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.9))
Spacer()
}
.padding(.horizontal, 20)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(
Capsule()
.fill(.white.opacity(0.15))
)
}
.buttonStyle(.plain)
// 4 circular icon buttons - ChatGPT style, fill width
HStack(spacing: 8) {
CircularIconButton(symbol: "camera", url: "homewidget://camera")
CircularIconButton(symbol: "photo.on.rectangle.angled", url: "homewidget://photos")
CircularIconButton(symbol: "waveform", url: "homewidget://mic")
CircularIconButton(symbol: "doc.on.clipboard", url: "homewidget://clipboard")
}
}
.padding(16)
}
}
// MARK: - Circular Icon Button (ChatGPT Style)
struct CircularIconButton: View {
let symbol: String
let url: String
var body: some View {
Link(destination: URL(string: url)!) {
Image(systemName: symbol)
.font(.system(size: 24, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.white.opacity(0.15))
)
}
.buttonStyle(.plain)
}
}
// MARK: - Widget Configuration
struct ConduitWidget: Widget {
let kind: String = "ConduitWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: ConduitProvider()) { entry in
if #available(iOS 17.0, *) {
ConduitWidgetEntryView(entry: entry)
.containerBackground(.clear, for: .widget)
} else {
ConduitWidgetEntryView(entry: entry)
}
}
.configurationDisplayName("Conduit")
.description("Quick access to chat, camera, photos, and voice.")
.supportedFamilies([.systemMedium])
.contentMarginsDisabled()
}
}
// MARK: - Preview
#Preview(as: .systemMedium) {
ConduitWidget()
} timeline: {
ConduitEntry(date: .now)
}

View File

@@ -0,0 +1,16 @@
//
// ConduitWidgetBundle.swift
// ConduitWidget
//
// Created by cogwheel on 07/12/25.
//
import WidgetKit
import SwiftUI
@main
struct ConduitWidgetBundle: WidgetBundle {
var body: some Widget {
ConduitWidget()
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.cogwheel.conduit</string>
</array>
</dict>
</plist>

View File

@@ -54,6 +54,8 @@ PODS:
- Flutter
- flutter_tts (0.0.1):
- Flutter
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- onnxruntime-c (1.22.0)
@@ -115,6 +117,7 @@ DEPENDENCIES:
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
@@ -162,6 +165,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_tts:
:path: ".symlinks/plugins/flutter_tts/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
package_info_plus:
@@ -208,6 +213,7 @@ SPEC CHECKSUMS:
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a
onnxruntime-objc: 83d28b87525bd971259a66e153ea32b5d023de19

View File

@@ -15,6 +15,9 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
F15AFEC42EE5499D00A1FABB /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F15AFEC32EE5499D00A1FABB /* WidgetKit.framework */; };
F15AFEC62EE5499D00A1FABB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F15AFEC52EE5499D00A1FABB /* SwiftUI.framework */; };
F15AFED12EE5499E00A1FABB /* ConduitWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F15AFEC22EE5499D00A1FABB /* ConduitWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
F1DBCF1D2E601A39004C2540 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F1DBCF132E601A39004C2540 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
F1E401255BF7F4649BBEC0E4 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3268AF100A34CCB865515F5F /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */
@@ -27,6 +30,13 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
F15AFECF2EE5499E00A1FABB /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = F15AFEC12EE5499D00A1FABB;
remoteInfo = ConduitWidgetExtension;
};
F1DBCF1B2E601A39004C2540 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
@@ -54,6 +64,7 @@
dstSubfolderSpec = 13;
files = (
F1DBCF1D2E601A39004C2540 /* ShareExtension.appex in Embed Foundation Extensions */,
F15AFED12EE5499E00A1FABB /* ConduitWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -88,11 +99,22 @@
A2C5C28DAB99348B62D0E111 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C334EBA4AE824079ECAEE9EE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
CF1093DCAFB438AD6653A379 /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = "<group>"; };
F15AFEC22EE5499D00A1FABB /* ConduitWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ConduitWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
F15AFEC32EE5499D00A1FABB /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
F15AFEC52EE5499D00A1FABB /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
F15AFED82EE549B700A1FABB /* ConduitWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConduitWidgetExtension.entitlements; sourceTree = "<group>"; };
F1DBCF132E601A39004C2540 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
F1DBCF242E601A7C004C2540 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
F15AFED52EE5499E00A1FABB /* Exceptions for "ConduitWidget" folder in "ConduitWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = F15AFEC12EE5499D00A1FABB /* ConduitWidgetExtension */;
};
F1DBCF222E601A39004C2540 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -103,6 +125,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
F15AFEC72EE5499D00A1FABB /* ConduitWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
F15AFED52EE5499E00A1FABB /* Exceptions for "ConduitWidget" folder in "ConduitWidgetExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = ConduitWidget;
sourceTree = "<group>";
};
F1DBCF142E601A39004C2540 /* ShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -134,6 +168,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F15AFEBF2EE5499D00A1FABB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
F15AFEC62EE5499D00A1FABB /* SwiftUI.framework in Frameworks */,
F15AFEC42EE5499D00A1FABB /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F1DBCF102E601A39004C2540 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -159,6 +202,8 @@
A2C5C28DAB99348B62D0E111 /* Pods_Runner.framework */,
3268AF100A34CCB865515F5F /* Pods_RunnerTests.framework */,
1D444AAE6AC78C530C74E51F /* Pods_ShareExtension.framework */,
F15AFEC32EE5499D00A1FABB /* WidgetKit.framework */,
F15AFEC52EE5499D00A1FABB /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -193,9 +238,11 @@
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
F15AFED82EE549B700A1FABB /* ConduitWidgetExtension.entitlements */,
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
F1DBCF142E601A39004C2540 /* ShareExtension */,
F15AFEC72EE5499D00A1FABB /* ConduitWidget */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
8C43905FA2E52A883F49D605 /* Pods */,
@@ -209,6 +256,7 @@
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
F1DBCF132E601A39004C2540 /* ShareExtension.appex */,
F15AFEC22EE5499D00A1FABB /* ConduitWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -269,12 +317,33 @@
);
dependencies = (
F1DBCF1C2E601A39004C2540 /* PBXTargetDependency */,
F15AFED02EE5499E00A1FABB /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
F15AFEC12EE5499D00A1FABB /* ConduitWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = F15AFED62EE5499E00A1FABB /* Build configuration list for PBXNativeTarget "ConduitWidgetExtension" */;
buildPhases = (
F15AFEBE2EE5499D00A1FABB /* Sources */,
F15AFEBF2EE5499D00A1FABB /* Frameworks */,
F15AFEC02EE5499D00A1FABB /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
F15AFEC72EE5499D00A1FABB /* ConduitWidget */,
);
name = ConduitWidgetExtension;
productName = ConduitWidgetExtension;
productReference = F15AFEC22EE5499D00A1FABB /* ConduitWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
F1DBCF122E601A39004C2540 /* ShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = F1DBCF232E601A39004C2540 /* Build configuration list for PBXNativeTarget "ShareExtension" */;
@@ -303,7 +372,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1640;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
@@ -315,6 +384,9 @@
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
F15AFEC12EE5499D00A1FABB = {
CreatedOnToolsVersion = 26.1.1;
};
F1DBCF122E601A39004C2540 = {
CreatedOnToolsVersion = 16.4;
};
@@ -336,6 +408,7 @@
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
F1DBCF122E601A39004C2540 /* ShareExtension */,
F15AFEC12EE5499D00A1FABB /* ConduitWidgetExtension */,
);
};
/* End PBXProject section */
@@ -358,6 +431,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F15AFEC02EE5499D00A1FABB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
F1DBCF112E601A39004C2540 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -500,6 +580,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F15AFEBE2EE5499D00A1FABB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
F1DBCF0F2E601A39004C2540 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -515,6 +602,11 @@
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
F15AFED02EE5499E00A1FABB /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = F15AFEC12EE5499D00A1FABB /* ConduitWidgetExtension */;
targetProxy = F15AFECF2EE5499E00A1FABB /* PBXContainerItemProxy */;
};
F1DBCF1C2E601A39004C2540 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = F1DBCF122E601A39004C2540 /* ShareExtension */;
@@ -855,6 +947,138 @@
};
name = Release;
};
F15AFED22EE5499E00A1FABB /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ConduitWidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = X2662V5DT2;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ConduitWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ConduitWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit.debug.ConduitWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
F15AFED32EE5499E00A1FABB /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ConduitWidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = X2662V5DT2;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ConduitWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ConduitWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit.ConduitWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
F15AFED42EE5499E00A1FABB /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ConduitWidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = X2662V5DT2;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ConduitWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ConduitWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit.ConduitWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
F1DBCF1F2E601A39004C2540 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 81AA439A1413A5BB88E56615 /* Pods-ShareExtension.debug.xcconfig */;
@@ -1023,6 +1247,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
F15AFED62EE5499E00A1FABB /* Build configuration list for PBXNativeTarget "ConduitWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F15AFED22EE5499E00A1FABB /* Debug */,
F15AFED32EE5499E00A1FABB /* Release */,
F15AFED42EE5499E00A1FABB /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
F1DBCF232E601A39004C2540 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -10,6 +10,7 @@ import '../providers/app_providers.dart';
import '../../features/auth/providers/unified_auth_providers.dart';
import '../services/navigation_service.dart';
import '../services/app_intents_service.dart';
import '../services/home_widget_service.dart';
import '../services/quick_actions_service.dart';
import '../models/conversation.dart';
import '../services/background_streaming_handler.dart';
@@ -172,6 +173,7 @@ class AppStartupFlow extends _$AppStartupFlow {
keepAlive(silentLoginCoordinatorProvider);
keepAlive(appIntentCoordinatorProvider);
keepAlive(quickActionsCoordinatorProvider);
keepAlive(homeWidgetCoordinatorProvider);
// Kick background model loading flow (non-blocking)
Future<void>.delayed(const Duration(milliseconds: 120), () {

View File

@@ -0,0 +1,409 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:home_widget/home_widget.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as path;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../features/auth/providers/unified_auth_providers.dart';
import '../../features/chat/providers/chat_providers.dart';
import '../../features/chat/services/file_attachment_service.dart';
import '../../shared/services/tasks/task_queue.dart';
import '../providers/app_providers.dart';
import '../utils/debug_logger.dart';
import 'app_intents_service.dart';
import 'navigation_service.dart';
part 'home_widget_service.g.dart';
/// Widget action identifiers matching native widget implementations.
class WidgetActions {
static const String newChat = 'new_chat';
static const String mic = 'mic';
static const String camera = 'camera';
static const String photos = 'photos';
static const String clipboard = 'clipboard';
}
/// App group identifier for iOS widget data sharing.
const String _appGroupId = 'group.app.cogwheel.conduit';
/// Android widget provider class name.
const String _androidWidgetName = 'ConduitWidgetProvider';
/// iOS widget kind identifier.
const String _iOSWidgetKind = 'ConduitWidget';
/// Handles home screen widget interactions for Android and iOS.
///
/// The widget provides quick actions:
/// - New Chat: Start a fresh conversation
/// - Camera: Take a photo and attach to chat
/// - Photos: Pick from gallery and attach to chat
/// - Clipboard: Paste clipboard content as prompt
@Riverpod(keepAlive: true)
class HomeWidgetCoordinator extends _$HomeWidgetCoordinator {
StreamSubscription<Uri?>? _widgetClickSubscription;
Uri? _pendingWidgetAction;
@override
FutureOr<void> build() async {
if (kIsWeb) return;
if (!Platform.isIOS && !Platform.isAndroid) return;
await _initialize();
ref.onDispose(() {
_widgetClickSubscription?.cancel();
});
}
Future<void> _initialize() async {
try {
// Set app group for iOS data sharing
if (Platform.isIOS) {
await HomeWidget.setAppGroupId(_appGroupId);
}
// Handle widget clicks
_widgetClickSubscription = HomeWidget.widgetClicked.listen(
_handleWidgetClick,
onError: (error) {
DebugLogger.error(
'home-widget-stream',
scope: 'widget',
error: error,
);
},
);
// Check for initial launch from widget
final initialUri = await HomeWidget.initiallyLaunchedFromHomeWidget();
if (initialUri != null) {
DebugLogger.log(
'Widget: Initial launch URI: $initialUri',
scope: 'widget',
);
// Store for later processing once app is ready
_pendingWidgetAction = initialUri;
// Try to process after a delay to allow router to initialize
_processInitialWidgetAction();
}
DebugLogger.log('Home widget service initialized', scope: 'widget');
} catch (error, stackTrace) {
DebugLogger.error(
'home-widget-init',
scope: 'widget',
error: error,
stackTrace: stackTrace,
);
}
}
/// Process initial widget action after ensuring router is ready.
Future<void> _processInitialWidgetAction() async {
if (_pendingWidgetAction == null) return;
// Wait for router to be attached and app to be ready
for (var i = 0; i < 50; i++) {
// Try for up to 5 seconds
await Future<void>.delayed(const Duration(milliseconds: 100));
// Check if router is available
if (NavigationService.currentRoute != null) {
DebugLogger.log(
'Widget: Router ready, processing pending action',
scope: 'widget',
);
final uri = _pendingWidgetAction;
_pendingWidgetAction = null;
await _handleWidgetClick(uri);
return;
}
}
DebugLogger.log(
'Widget: Timeout waiting for router, clearing pending action',
scope: 'widget',
);
_pendingWidgetAction = null;
}
Future<void> _handleWidgetClick(Uri? uri) async {
if (uri == null) return;
// If router isn't ready yet, store for later
if (NavigationService.currentRoute == null) {
DebugLogger.log(
'Widget: Router not ready, storing action for later',
scope: 'widget',
);
_pendingWidgetAction = uri;
_processInitialWidgetAction();
return;
}
final action = uri.host.isNotEmpty
? uri.host
: uri.pathSegments.firstOrNull;
if (action == null || action.isEmpty) {
// Default action: open new chat
await _handleNewChat();
return;
}
DebugLogger.log('Widget action: $action', scope: 'widget');
switch (action) {
case WidgetActions.newChat:
await _handleNewChat();
break;
case WidgetActions.mic:
await _handleMic();
break;
case WidgetActions.camera:
await _handleCamera();
break;
case WidgetActions.photos:
await _handlePhotos();
break;
case WidgetActions.clipboard:
await _handleClipboard();
break;
default:
DebugLogger.log('Unknown widget action: $action', scope: 'widget');
await _handleNewChat();
}
}
Future<void> _handleNewChat() async {
DebugLogger.log('Widget: Starting new chat', scope: 'widget');
await _waitForNavigation();
await ref
.read(appIntentCoordinatorProvider.notifier)
.openChatFromExternal(focusComposer: true, resetChat: true);
}
Future<void> _handleMic() async {
DebugLogger.log('Widget: Starting voice call', scope: 'widget');
await _waitForNavigation();
try {
await ref
.read(appIntentCoordinatorProvider.notifier)
.startVoiceCallFromExternal();
} catch (error, stackTrace) {
DebugLogger.error(
'home-widget-mic',
scope: 'widget',
error: error,
stackTrace: stackTrace,
);
// Fall back to opening chat with focus
await ref
.read(appIntentCoordinatorProvider.notifier)
.openChatFromExternal(focusComposer: true, resetChat: true);
}
}
Future<void> _handleCamera() async {
DebugLogger.log('Widget: Opening camera', scope: 'widget');
await _waitForNavigation();
// Navigate to chat first
await ref
.read(appIntentCoordinatorProvider.notifier)
.openChatFromExternal(focusComposer: false, resetChat: true);
// Wait for navigation to settle
await Future<void>.delayed(const Duration(milliseconds: 100));
// Check auth state
final navState = ref.read(authNavigationStateProvider);
if (navState != AuthNavigationState.authenticated) {
DebugLogger.log('Widget: Not authenticated for camera', scope: 'widget');
return;
}
try {
final picker = ImagePicker();
final image = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 85,
);
if (image != null) {
await _attachFile(File(image.path));
}
} catch (error, stackTrace) {
DebugLogger.error(
'home-widget-camera',
scope: 'widget',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> _handlePhotos() async {
DebugLogger.log('Widget: Opening photo picker', scope: 'widget');
await _waitForNavigation();
// Navigate to chat first
await ref
.read(appIntentCoordinatorProvider.notifier)
.openChatFromExternal(focusComposer: false, resetChat: true);
// Wait for navigation to settle
await Future<void>.delayed(const Duration(milliseconds: 100));
// Check auth state
final navState = ref.read(authNavigationStateProvider);
if (navState != AuthNavigationState.authenticated) {
DebugLogger.log('Widget: Not authenticated for photos', scope: 'widget');
return;
}
try {
final picker = ImagePicker();
final images = await picker.pickMultiImage(imageQuality: 85);
if (images.isNotEmpty) {
for (final image in images) {
await _attachFile(File(image.path));
}
}
} catch (error, stackTrace) {
DebugLogger.error(
'home-widget-photos',
scope: 'widget',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> _handleClipboard() async {
DebugLogger.log('Widget: Pasting from clipboard', scope: 'widget');
await _waitForNavigation();
try {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text?.trim();
if (text == null || text.isEmpty) {
DebugLogger.log('Widget: Clipboard is empty', scope: 'widget');
// Still open chat even if clipboard is empty
await ref
.read(appIntentCoordinatorProvider.notifier)
.openChatFromExternal(focusComposer: true, resetChat: true);
return;
}
await ref
.read(appIntentCoordinatorProvider.notifier)
.openChatFromExternal(
prompt: text,
focusComposer: true,
resetChat: true,
);
} catch (error, stackTrace) {
DebugLogger.error(
'home-widget-clipboard',
scope: 'widget',
error: error,
stackTrace: stackTrace,
);
// Fall back to just opening chat
await ref
.read(appIntentCoordinatorProvider.notifier)
.openChatFromExternal(focusComposer: true, resetChat: true);
}
}
/// Wait for the navigation system to be ready.
Future<void> _waitForNavigation() async {
// Wait for bindings to be initialized
WidgetsBinding.instance.addPostFrameCallback((_) {});
await Future<void>.delayed(const Duration(milliseconds: 50));
}
Future<void> _attachFile(File file) async {
if (!ref.mounted) return;
// Warm the attachment service
final _ = ref.read(fileAttachmentServiceProvider);
final notifier = ref.read(attachedFilesProvider.notifier);
final taskQueue = ref.read(taskQueueProvider.notifier);
final activeConv = ref.read(activeConversationProvider);
final attachment = LocalAttachment(
file: file,
displayName: path.basename(file.path),
);
notifier.addFiles([attachment]);
try {
await taskQueue.enqueueUploadMedia(
conversationId: activeConv?.id,
filePath: file.path,
fileName: attachment.displayName,
fileSize: await file.length(),
);
} catch (error, stackTrace) {
DebugLogger.error(
'home-widget-upload',
scope: 'widget',
error: error,
stackTrace: stackTrace,
);
}
// Focus the composer after attaching
final tick = ref.read(inputFocusTriggerProvider);
ref.read(inputFocusTriggerProvider.notifier).set(tick + 1);
}
/// Update widget data displayed on home screen.
///
/// Call this when app state changes that should be reflected in widget.
Future<void> updateWidgetData() async {
if (kIsWeb) return;
if (!Platform.isIOS && !Platform.isAndroid) return;
try {
// For now, we just trigger a widget update
// In the future, we could pass data like recent conversations
if (Platform.isAndroid) {
await HomeWidget.updateWidget(androidName: _androidWidgetName);
} else if (Platform.isIOS) {
await HomeWidget.updateWidget(iOSName: _iOSWidgetKind);
}
DebugLogger.log('Widget data updated', scope: 'widget');
} catch (error, stackTrace) {
DebugLogger.error(
'home-widget-update',
scope: 'widget',
error: error,
stackTrace: stackTrace,
);
}
}
}
/// Provider to trigger home widget initialization at app startup.
final homeWidgetInitializerProvider = Provider<void>((ref) {
if (kIsWeb) return;
if (!Platform.isIOS && !Platform.isAndroid) return;
// Initialize the coordinator which sets up widget click handling
ref.watch(homeWidgetCoordinatorProvider);
});

View File

@@ -1787,6 +1787,26 @@
}
}
},
"widgetAskConduit": "Ask Conduit",
"@widgetAskConduit": {
"description": "Main button text on the home screen widget."
},
"widgetCamera": "Camera",
"@widgetCamera": {
"description": "Camera button label on the home screen widget."
},
"widgetPhotos": "Photos",
"@widgetPhotos": {
"description": "Photos button label on the home screen widget."
},
"widgetClipboard": "Clipboard",
"@widgetClipboard": {
"description": "Clipboard button label on the home screen widget."
},
"widgetDescription": "Quick access to Conduit chat with camera, photos, and clipboard shortcuts",
"@widgetDescription": {
"description": "Description shown in the widget picker when adding the widget."
},
"mermaidPreviewUnavailable": "Mermaid preview is not available on this platform.",
"@mermaidPreviewUnavailable": {
"description": "Shown when Mermaid diagrams cannot be rendered on the current platform."

View File

@@ -725,6 +725,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.3"
home_widget:
dependency: "direct main"
description:
name: home_widget
sha256: "908d033514a981f829fd98213909e11a428104327be3b422718aa643ac9d084a"
url: "https://pub.dev"
source: hosted
version: "0.8.1"
hotreloader:
dependency: transitive
description:

View File

@@ -77,6 +77,7 @@ dependencies:
quick_actions: 1.1.0
flutter_svg: ^2.2.3
html_unescape: ^2.0.0
home_widget: ^0.8.1
# Clipboard functionality is available through flutter/services (part of Flutter SDK)