@tduncan / Cortana / commits / 8570f2c

feat: add OS home screen widgets with deep link navigation to app features

abdulmominsakib committed Aug 24, 2026 at 23:19 UTC 8570f2ce42a6062c85c4249e599624064241bae2
27 files changed +1397 -3
README.md
+1 -1
@@ -102,7 +102,7 @@ LocalMind is rapidly evolving. Here is what's coming next:
102 - [x] **Multimodal Support**: Attach images and documents for vision-capable models.
103 - [x] **Tablet/Desktop Optimization**: A split-view layout for large-screen productivity.
104 - [x] **Export Options**: Export your chats to Markdown or PDF to share or archive.
105 -- [ ] **Quick Shortcuts**: OS-level widgets and shortcuts for launching new chats instantly.
105 +- [x] **Quick Shortcuts**: OS-level widgets and shortcuts for launching new chats instantly.
106 - [x] **Voice-to-Voice Mode**: Engage in real-time, low-latency spoken conversations with local models.
107 - [x] **S3 Cloud Sync**: End-to-end encrypted cloud sync for settings, custom personas, and chats in your own S3 server.
108
android/app/src/main/AndroidManifest.xml
+19
@@ -49,6 +49,14 @@
49 <action android:name="android.intent.action.ASSIST" />
50 <category android:name="android.intent.category.DEFAULT" />
51 </intent-filter>
52 + <intent-filter>
53 + <action android:name="android.intent.action.VIEW" />
54 + <category android:name="android.intent.category.DEFAULT" />
55 + <category android:name="android.intent.category.BROWSABLE" />
56 + <data
57 + android:host="widget"
58 + android:scheme="localmind" />
59 + </intent-filter>
60 </activity>
61 <!-- Don't delete the meta-data below.
62 This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
@@ -56,6 +64,17 @@
64 android:name="flutterEmbedding"
65 android:value="2" />
66
67 + <receiver
68 + android:name=".LocalMindWidgetProvider"
69 + android:exported="true">
70 + <intent-filter>
71 + <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
72 + </intent-filter>
73 + <meta-data
74 + android:name="android.appwidget.provider"
75 + android:resource="@xml/localmind_appwidget_info" />
76 + </receiver>
77 +
78 <service
79 android:name=".ChatForegroundService"
80 android:enabled="true"
android/app/src/main/kotlin/pro/momin/localmind/LocalMindWidgetProvider.kt new
+67
@@ -0,0 +1,67 @@
1 +package pro.momin.localmind
2 +
3 +import android.appwidget.AppWidgetManager
4 +import android.content.Context
5 +import android.content.SharedPreferences
6 +import android.net.Uri
7 +import android.widget.RemoteViews
8 +import es.antonborri.home_widget.HomeWidgetLaunchIntent
9 +import es.antonborri.home_widget.HomeWidgetProvider
10 +
11 +class LocalMindWidgetProvider : HomeWidgetProvider() {
12 + override fun onUpdate(
13 + context: Context,
14 + appWidgetManager: AppWidgetManager,
15 + appWidgetIds: IntArray,
16 + widgetData: SharedPreferences
17 + ) {
18 + appWidgetIds.forEach { widgetId ->
19 + val views = RemoteViews(context.packageName, R.layout.localmind_appwidget).apply {
20 + val modelName = widgetData.getString("model_name", null)
21 + val isConfigured = widgetData.getBoolean("is_configured", false)
22 +
23 + if (isConfigured && !modelName.isNullOrEmpty()) {
24 + setTextViewText(R.id.appwidget_model_name, modelName)
25 + setTextViewText(R.id.appwidget_btn_ask_text, modelName)
26 + } else {
27 + setTextViewText(R.id.appwidget_model_name, context.getString(R.string.widget_no_model))
28 + setTextViewText(R.id.appwidget_btn_ask_text, "Ask Model")
29 + }
30 +
31 + // Search Bar -> launches chat with focus
32 + val searchIntent = HomeWidgetLaunchIntent.getActivity(
33 + context,
34 + MainActivity::class.java,
35 + Uri.parse("localmind://widget?action=chat")
36 + )
37 + setOnClickPendingIntent(R.id.appwidget_search_bar, searchIntent)
38 +
39 + // Voice Button -> launches voice assistant
40 + val voiceIntent = HomeWidgetLaunchIntent.getActivity(
41 + context,
42 + MainActivity::class.java,
43 + Uri.parse("localmind://widget?action=voice")
44 + )
45 + setOnClickPendingIntent(R.id.appwidget_btn_voice, voiceIntent)
46 +
47 + // New Chat Button -> launches new chat
48 + val newChatIntent = HomeWidgetLaunchIntent.getActivity(
49 + context,
50 + MainActivity::class.java,
51 + Uri.parse("localmind://widget?action=new_chat")
52 + )
53 + setOnClickPendingIntent(R.id.appwidget_btn_new_chat, newChatIntent)
54 +
55 + // Ask Model Button -> launches chat
56 + val askModelIntent = HomeWidgetLaunchIntent.getActivity(
57 + context,
58 + MainActivity::class.java,
59 + Uri.parse("localmind://widget?action=chat")
60 + )
61 + setOnClickPendingIntent(R.id.appwidget_btn_ask_model, askModelIntent)
62 + }
63 +
64 + appWidgetManager.updateAppWidget(widgetId, views)
65 + }
66 + }
67 +}
android/app/src/main/res/drawable/ic_widget_chat.xml new
+13
@@ -0,0 +1,13 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:width="24dp"
4 + android:height="24dp"
5 + android:viewportWidth="24"
6 + android:viewportHeight="24">
7 + <path
8 + android:strokeWidth="2"
9 + android:strokeColor="#9CA3AF"
10 + android:strokeLineCap="round"
11 + android:strokeLineJoin="round"
12 + android:pathData="M21,15 C21,15.5304 20.7893,16.0391 20.4142,16.4142 C20.0391,16.7893 19.5304,17 19,17 L7,17 L3,21 L3,5 C3,4.46957 3.21071,3.96086 3.58579,3.58579 C3.96086,3.21071 4.46957,3 5,3 L19,3 C19.5304,3 20.0391,3.21071 20.4142,3.58579 C20.7893,3.96086 21,4.46957 21,5 L21,15 Z" />
13 +</vector>
android/app/src/main/res/drawable/ic_widget_mic.xml new
+25
@@ -0,0 +1,25 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:width="24dp"
4 + android:height="24dp"
5 + android:viewportWidth="24"
6 + android:viewportHeight="24">
7 + <path
8 + android:strokeWidth="2"
9 + android:strokeColor="#9CA3AF"
10 + android:strokeLineCap="round"
11 + android:strokeLineJoin="round"
12 + android:pathData="M12,1 C10.3431,1 9,2.34315 9,4 L9,12 C9,13.6569 10.3431,15 12,15 C13.6569,15 15,13.6569 15,12 L15,4 C15,2.34315 13.6569,1 12,1 Z" />
13 + <path
14 + android:strokeWidth="2"
15 + android:strokeColor="#9CA3AF"
16 + android:strokeLineCap="round"
17 + android:strokeLineJoin="round"
18 + android:pathData="M19,10 L19,12 C19,15.866 15.866,19 12,19 C8.13401,19 5,15.866 5,12 L5,10" />
19 + <path
20 + android:strokeWidth="2"
21 + android:strokeColor="#9CA3AF"
22 + android:strokeLineCap="round"
23 + android:strokeLineJoin="round"
24 + android:pathData="M12,19 L12,23 M8,23 L16,23" />
25 +</vector>
android/app/src/main/res/drawable/ic_widget_search.xml new
+13
@@ -0,0 +1,13 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:width="24dp"
4 + android:height="24dp"
5 + android:viewportWidth="24"
6 + android:viewportHeight="24">
7 + <path
8 + android:strokeWidth="2"
9 + android:strokeColor="#9CA3AF"
10 + android:strokeLineCap="round"
11 + android:strokeLineJoin="round"
12 + android:pathData="M11,19 C15.4183,19 19,15.4183 19,11 C19,6.58172 15.4183,3 11,3 C6.58172,3 3,6.58172 3,11 C3,15.4183 6.58172,19 11,19 Z M21,21 L16.65,16.65" />
13 +</vector>
android/app/src/main/res/drawable/ic_widget_sparkle.xml new
+10
@@ -0,0 +1,10 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:width="24dp"
4 + android:height="24dp"
5 + android:viewportWidth="24"
6 + android:viewportHeight="24">
7 + <path
8 + android:fillColor="#E5E7EB"
9 + android:pathData="M12,2 L14.09,8.26 L20,9.27 L15.55,13.97 L16.82,20 L12,16.77 L7.18,20 L8.45,13.97 L4,9.27 L9.91,8.26 L12,2 Z" />
10 +</vector>
android/app/src/main/res/drawable/widget_badge_bg.xml new
+9
@@ -0,0 +1,9 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<shape xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:shape="rectangle">
4 + <solid android:color="#1F232D" />
5 + <stroke
6 + android:width="1dp"
7 + android:color="#353A47" />
8 + <corners android:radius="6dp" />
9 +</shape>
android/app/src/main/res/drawable/widget_button_bg.xml new
+9
@@ -0,0 +1,9 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<shape xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:shape="rectangle">
4 + <solid android:color="#2B2F3A" />
5 + <stroke
6 + android:width="1dp"
7 + android:color="#3D4352" />
8 + <corners android:radius="20dp" />
9 +</shape>
android/app/src/main/res/drawable/widget_card_bg.xml new
+9
@@ -0,0 +1,9 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<shape xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:shape="rectangle">
4 + <solid android:color="#17191E" />
5 + <stroke
6 + android:width="1dp"
7 + android:color="#2D313A" />
8 + <corners android:radius="18dp" />
9 +</shape>
android/app/src/main/res/drawable/widget_search_bg.xml new
+9
@@ -0,0 +1,9 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<shape xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:shape="rectangle">
4 + <solid android:color="#23262F" />
5 + <stroke
6 + android:width="1dp"
7 + android:color="#363A45" />
8 + <corners android:radius="12dp" />
9 +</shape>
android/app/src/main/res/layout/localmind_appwidget.xml new
+162
@@ -0,0 +1,162 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:id="@+id/widget_root"
4 + android:layout_width="match_parent"
5 + android:layout_height="match_parent"
6 + android:background="@drawable/widget_card_bg"
7 + android:orientation="vertical"
8 + android:padding="12dp">
9 +
10 + <!-- Header: App Name & Active Model Badge -->
11 + <LinearLayout
12 + android:id="@+id/widget_header"
13 + android:layout_width="match_parent"
14 + android:layout_height="wrap_content"
15 + android:gravity="center_vertical"
16 + android:orientation="horizontal">
17 +
18 + <ImageView
19 + android:id="@+id/appwidget_icon"
20 + android:layout_width="16dp"
21 + android:layout_height="16dp"
22 + android:layout_marginEnd="6dp"
23 + android:contentDescription="@string/app_name"
24 + android:src="@drawable/ic_widget_sparkle" />
25 +
26 + <TextView
27 + android:id="@+id/appwidget_title"
28 + android:layout_width="0dp"
29 + android:layout_height="wrap_content"
30 + android:layout_weight="1"
31 + android:text="@string/app_name"
32 + android:textColor="#F3F4F6"
33 + android:textSize="13sp"
34 + android:textStyle="bold" />
35 +
36 + <TextView
37 + android:id="@+id/appwidget_model_name"
38 + android:layout_width="wrap_content"
39 + android:layout_height="wrap_content"
40 + android:background="@drawable/widget_badge_bg"
41 + android:ellipsize="end"
42 + android:maxLines="1"
43 + android:maxWidth="160dp"
44 + android:paddingBottom="2dp"
45 + android:paddingEnd="8dp"
46 + android:paddingStart="8dp"
47 + android:paddingTop="2dp"
48 + android:text="@string/widget_no_model"
49 + android:textColor="#9CA3AF"
50 + android:textSize="11sp" />
51 + </LinearLayout>
52 +
53 + <!-- Search/Prompt Bar Trigger -->
54 + <LinearLayout
55 + android:id="@+id/appwidget_search_bar"
56 + android:layout_width="match_parent"
57 + android:layout_height="42dp"
58 + android:layout_marginTop="8dp"
59 + android:background="@drawable/widget_search_bg"
60 + android:gravity="center_vertical"
61 + android:orientation="horizontal"
62 + android:paddingEnd="8dp"
63 + android:paddingStart="10dp">
64 +
65 + <ImageView
66 + android:id="@+id/appwidget_search_icon"
67 + android:layout_width="18dp"
68 + android:layout_height="18dp"
69 + android:layout_marginEnd="8dp"
70 + android:contentDescription="@string/widget_ask_hint"
71 + android:src="@drawable/ic_widget_search" />
72 +
73 + <TextView
74 + android:id="@+id/appwidget_search_text"
75 + android:layout_width="0dp"
76 + android:layout_height="wrap_content"
77 + android:layout_weight="1"
78 + android:ellipsize="end"
79 + android:maxLines="1"
80 + android:text="@string/widget_ask_hint"
81 + android:textColor="#9CA3AF"
82 + android:textSize="13sp" />
83 +
84 + <ImageView
85 + android:id="@+id/appwidget_btn_voice"
86 + android:layout_width="28dp"
87 + android:layout_height="28dp"
88 + android:background="@drawable/widget_button_bg"
89 + android:contentDescription="@string/widget_voice"
90 + android:padding="5dp"
91 + android:src="@drawable/ic_widget_mic" />
92 + </LinearLayout>
93 +
94 + <!-- Quick Action Buttons -->
95 + <LinearLayout
96 + android:id="@+id/widget_actions"
97 + android:layout_width="match_parent"
98 + android:layout_height="wrap_content"
99 + android:layout_marginTop="8dp"
100 + android:gravity="center_vertical"
101 + android:orientation="horizontal">
102 +
103 + <LinearLayout
104 + android:id="@+id/appwidget_btn_new_chat"
105 + android:layout_width="0dp"
106 + android:layout_height="32dp"
107 + android:layout_marginEnd="6dp"
108 + android:layout_weight="1"
109 + android:background="@drawable/widget_button_bg"
110 + android:gravity="center"
111 + android:orientation="horizontal"
112 + android:paddingStart="8dp"
113 + android:paddingEnd="8dp">
114 +
115 + <ImageView
116 + android:layout_width="14dp"
117 + android:layout_height="14dp"
118 + android:layout_marginEnd="4dp"
119 + android:contentDescription="@string/widget_new_chat"
120 + android:src="@drawable/ic_widget_chat" />
121 +
122 + <TextView
123 + android:layout_width="wrap_content"
124 + android:layout_height="wrap_content"
125 + android:text="@string/widget_new_chat"
126 + android:textColor="#E5E7EB"
127 + android:textSize="12sp"
128 + android:textStyle="bold" />
129 + </LinearLayout>
130 +
131 + <LinearLayout
132 + android:id="@+id/appwidget_btn_ask_model"
133 + android:layout_width="0dp"
134 + android:layout_height="32dp"
135 + android:layout_marginStart="6dp"
136 + android:layout_weight="1"
137 + android:background="@drawable/widget_button_bg"
138 + android:gravity="center"
139 + android:orientation="horizontal"
140 + android:paddingStart="8dp"
141 + android:paddingEnd="8dp">
142 +
143 + <ImageView
144 + android:layout_width="14dp"
145 + android:layout_height="14dp"
146 + android:layout_marginEnd="4dp"
147 + android:contentDescription="@string/widget_ask_hint"
148 + android:src="@drawable/ic_widget_sparkle" />
149 +
150 + <TextView
151 + android:id="@+id/appwidget_btn_ask_text"
152 + android:layout_width="wrap_content"
153 + android:layout_height="wrap_content"
154 + android:ellipsize="end"
155 + android:maxLines="1"
156 + android:text="Ask Model"
157 + android:textColor="#E5E7EB"
158 + android:textSize="12sp"
159 + android:textStyle="bold" />
160 + </LinearLayout>
161 + </LinearLayout>
162 +</LinearLayout>
android/app/src/main/res/values/strings.xml new
+8
@@ -0,0 +1,8 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<resources>
3 + <string name="app_name">LocalMind</string>
4 + <string name="widget_ask_hint">Ask LocalMind...</string>
5 + <string name="widget_new_chat">New Chat</string>
6 + <string name="widget_voice">Voice</string>
7 + <string name="widget_no_model">No default model</string>
8 +</resources>
android/app/src/main/res/xml/localmind_appwidget_info.xml new
+11
@@ -0,0 +1,11 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:minWidth="250dp"
4 + android:minHeight="110dp"
5 + android:targetCellWidth="4"
6 + android:targetCellHeight="2"
7 + android:updatePeriodMillis="86400000"
8 + android:initialLayout="@layout/localmind_appwidget"
9 + android:resizeMode="horizontal|vertical"
10 + android:widgetCategory="home_screen"
11 + android:description="@string/app_name" />
ios/Runner/Info.plist
+13
@@ -31,6 +31,19 @@
31 <string>????</string>
32 <key>CFBundleVersion</key>
33 <string>$(FLUTTER_BUILD_NUMBER)</string>
34 + <key>CFBundleURLTypes</key>
35 + <array>
36 + <dict>
37 + <key>CFBundleTypeRole</key>
38 + <string>Editor</string>
39 + <key>CFBundleURLName</key>
40 + <string>pro.momin.localmind</string>
41 + <key>CFBundleURLSchemes</key>
42 + <array>
43 + <string>localmind</string>
44 + </array>
45 + </dict>
46 + </array>
47 <key>LSRequiresIPhoneOS</key>
48 <true/>
49 <key>UIApplicationSceneManifest</key>
lib/app.dart
+6 -1
@@ -17,6 +17,7 @@ import 'features/chat/views/chat_screen.dart';
17 import 'features/conversations/views/chat_history_screen.dart';
18 import 'features/mcp/views/mcp_tools_screen.dart';
19 import 'features/on_device/views/model_manager_screen.dart';
20 +import 'features/os_widget/views/os_widget_invocation_host.dart';
21 import 'features/onboarding/screens/onboarding_language_screen.dart';
22 import 'features/onboarding/screens/onboarding_model_download_screen.dart';
23 import 'features/onboarding/screens/onboarding_notification_permission_screen.dart';
@@ -101,7 +102,11 @@ final routerProvider = Provider<GoRouter>((ref) {
102 ShellRoute(
103 navigatorKey: _shellNavigatorKey,
104 builder: (context, state, child) {
104 - return AndroidAssistantInvocationHost(child: AppShell(child: child));
105 + return OsWidgetInvocationHost(
106 + child: AndroidAssistantInvocationHost(
107 + child: AppShell(child: child),
108 + ),
109 + );
110 },
111 routes: [
112 GoRoute(
lib/features/chat/views/components/chat_input_bar.dart
+25
@@ -22,6 +22,7 @@ import '../../utils/attachment_helpers.dart';
22 import '../../utils/image_upload_utils.dart';
23 import '../../../models/views/model_picker_sheet.dart';
24 import '../../../models/components/thinking_mode_chip.dart';
25 +import '../../../os_widget/providers/os_widget_providers.dart';
26 import 'attach_sheet.dart';
27 import 'image_preview_dialog.dart';
28 import 'model_chip.dart';
@@ -132,6 +133,19 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
133 _handleGenerateAiUser();
134 }
135 });
136 +
137 + WidgetsBinding.instance.addPostFrameCallback((_) {
138 + if (!mounted) return;
139 + final pendingPrompt = ref.read(widgetPendingPromptProvider);
140 + if (pendingPrompt != null && pendingPrompt.isNotEmpty) {
141 + _controller.text = pendingPrompt;
142 + _controller.selection = TextSelection.fromPosition(
143 + TextPosition(offset: _controller.text.length),
144 + );
145 + _activeFocus.requestFocus();
146 + ref.read(widgetPendingPromptProvider.notifier).consumePrompt();
147 + }
148 + });
149 }
150
151 @override
@@ -809,6 +823,17 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
823 }
824 }
825
826 + ref.listen<String?>(widgetPendingPromptProvider, (previous, next) {
827 + if (next != null && next.isNotEmpty) {
828 + _controller.text = next;
829 + _controller.selection = TextSelection.fromPosition(
830 + TextPosition(offset: _controller.text.length),
831 + );
832 + _activeFocus.requestFocus();
833 + ref.read(widgetPendingPromptProvider.notifier).consumePrompt();
834 + }
835 + });
836 +
837 ref.listen<String?>(sttProvider.select((s) => s.error), (previous, next) {
838 if (next != null) {
839 final voiceState = ref.read(voiceModeProvider);
lib/features/os_widget/data/models/os_widget_models.dart new
+29
@@ -0,0 +1,29 @@
1 +enum OsWidgetAction { chat, newChat, voice, unknown }
2 +
3 +class OsWidgetInvocation {
4 + final OsWidgetAction action;
5 + final String? prompt;
6 + final Uri? uri;
7 +
8 + const OsWidgetInvocation({required this.action, this.prompt, this.uri});
9 +
10 + factory OsWidgetInvocation.fromUri(Uri uri) {
11 + final actionParam =
12 + uri.queryParameters['action'] ??
13 + (uri.host.isNotEmpty && uri.host != 'widget' ? uri.host : null);
14 + final prompt = uri.queryParameters['prompt'] ?? uri.queryParameters['text'];
15 +
16 + final action = switch (actionParam) {
17 + 'new_chat' || 'newChat' => OsWidgetAction.newChat,
18 + 'voice' => OsWidgetAction.voice,
19 + 'chat' => OsWidgetAction.chat,
20 + _ => OsWidgetAction.chat,
21 + };
22 +
23 + return OsWidgetInvocation(
24 + action: action,
25 + prompt: prompt != null && prompt.trim().isNotEmpty ? prompt.trim() : null,
26 + uri: uri,
27 + );
28 + }
29 +}
lib/features/os_widget/data/os_widget_service.dart new
+89
@@ -0,0 +1,89 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter/foundation.dart';
4 +import 'package:home_widget/home_widget.dart';
5 +
6 +import '../../../core/logger/app_logger.dart';
7 +import 'models/os_widget_models.dart';
8 +
9 +class OsWidgetService {
10 + OsWidgetService({this.isSupportedOverride});
11 +
12 + final bool? isSupportedOverride;
13 + final StreamController<OsWidgetInvocation> _invocations =
14 + StreamController<OsWidgetInvocation>.broadcast();
15 +
16 + StreamSubscription<Uri?>? _widgetClickSubscription;
17 + bool _initialized = false;
18 + bool _disposed = false;
19 +
20 + static const String androidWidgetName = 'LocalMindWidgetProvider';
21 + static const String iOSWidgetName = 'LocalMindWidget';
22 + static const String appGroupId = 'group.pro.momin.localmind';
23 +
24 + bool get isSupportedPlatform =>
25 + isSupportedOverride ??
26 + (!kIsWeb &&
27 + (defaultTargetPlatform == TargetPlatform.android ||
28 + defaultTargetPlatform == TargetPlatform.iOS));
29 +
30 + Stream<OsWidgetInvocation> get invocations => _invocations.stream;
31 +
32 + Future<void> initialize() async {
33 + if (!isSupportedPlatform || _initialized || _disposed) return;
34 + _initialized = true;
35 +
36 + try {
37 + await HomeWidget.setAppGroupId(appGroupId);
38 +
39 + // Handle initial launch from widget
40 + final initialUri = await HomeWidget.initiallyLaunchedFromHomeWidget();
41 + if (initialUri != null && !_disposed) {
42 + Log.info('App initially launched from widget: $initialUri');
43 + _invocations.add(OsWidgetInvocation.fromUri(initialUri));
44 + }
45 +
46 + // Listen to subsequent clicks while app is alive
47 + _widgetClickSubscription = HomeWidget.widgetClicked.listen((uri) {
48 + if (uri != null && !_disposed) {
49 + Log.info('Widget clicked: $uri');
50 + _invocations.add(OsWidgetInvocation.fromUri(uri));
51 + }
52 + });
53 + } catch (e) {
54 + Log.error('Failed to initialize OsWidgetService: $e');
55 + }
56 + }
57 +
58 + Future<void> updateWidgetSnapshot({
59 + required String? modelName,
60 + required bool isConfigured,
61 + }) async {
62 + if (!isSupportedPlatform) return;
63 + try {
64 + await HomeWidget.setAppGroupId(appGroupId);
65 + await HomeWidget.saveWidgetData<String>('model_name', modelName ?? '');
66 + await HomeWidget.saveWidgetData<bool>('is_configured', isConfigured);
67 +
68 + await HomeWidget.updateWidget(
69 + name: androidWidgetName,
70 + iOSName: iOSWidgetName,
71 + );
72 + } catch (e) {
73 + Log.warning('Failed to update OS widget snapshot: $e');
74 + }
75 + }
76 +
77 + void dispatchInvocation(OsWidgetInvocation invocation) {
78 + if (!_disposed) {
79 + _invocations.add(invocation);
80 + }
81 + }
82 +
83 + Future<void> dispose() async {
84 + if (_disposed) return;
85 + _disposed = true;
86 + await _widgetClickSubscription?.cancel();
87 + await _invocations.close();
88 + }
89 +}
lib/features/os_widget/providers/os_widget_providers.dart new
+61
@@ -0,0 +1,61 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +
3 +import '../../../core/providers/app_providers.dart';
4 +import '../../models/data/models/model_info.dart';
5 +import '../../servers/providers/server_providers.dart';
6 +import '../data/os_widget_service.dart';
7 +
8 +final osWidgetServiceProvider = Provider<OsWidgetService>((ref) {
9 + final service = OsWidgetService();
10 + ref.onDispose(service.dispose);
11 + return service;
12 +});
13 +
14 +class WidgetPendingPromptNotifier extends Notifier<String?> {
15 + @override
16 + String? build() => null;
17 +
18 + void setPrompt(String? prompt) {
19 + state = prompt;
20 + }
21 +
22 + String? consumePrompt() {
23 + final prompt = state;
24 + state = null;
25 + return prompt;
26 + }
27 +}
28 +
29 +final widgetPendingPromptProvider =
30 + NotifierProvider<WidgetPendingPromptNotifier, String?>(
31 + WidgetPendingPromptNotifier.new,
32 + );
33 +
34 +final osWidgetSnapshotSyncProvider = Provider<void>((ref) {
35 + final settings = ref.watch(settingsProvider);
36 + final defaultModelId = settings.defaultModelId;
37 + final defaultModelServerId = settings.defaultModelServerId;
38 + final service = ref.watch(osWidgetServiceProvider);
39 +
40 + if (!service.isSupportedPlatform) return;
41 +
42 + final isConfigured =
43 + defaultModelId != null &&
44 + defaultModelId.isNotEmpty &&
45 + defaultModelServerId != null &&
46 + defaultModelServerId.isNotEmpty;
47 +
48 + if (!isConfigured) {
49 + service.updateWidgetSnapshot(modelName: null, isConfigured: false);
50 + return;
51 + }
52 +
53 + final availableAsync = ref.watch(
54 + availableModelsProvider(defaultModelServerId),
55 + );
56 + final models = availableAsync.value ?? const <ModelInfo>[];
57 + final model = models.where((m) => m.id == defaultModelId).firstOrNull;
58 + final modelName = model?.displayName ?? defaultModelId;
59 +
60 + service.updateWidgetSnapshot(modelName: modelName, isConfigured: true);
61 +});
lib/features/os_widget/views/components/model_loading_overlay.dart new
+167
@@ -0,0 +1,167 @@
1 +import 'dart:ui';
2 +
3 +import 'package:flutter/material.dart';
4 +import 'package:hugeicons/hugeicons.dart';
5 +import 'package:loading_animation_widget/loading_animation_widget.dart';
6 +import 'package:shadcn_ui/shadcn_ui.dart';
7 +
8 +class ModelLoadingOverlay extends StatelessWidget {
9 + const ModelLoadingOverlay({
10 + required this.modelName,
11 + this.serverName,
12 + this.statusMessage = 'Loading model into memory...',
13 + this.onCancel,
14 + super.key,
15 + });
16 +
17 + final String modelName;
18 + final String? serverName;
19 + final String statusMessage;
20 + final VoidCallback? onCancel;
21 +
22 + @override
23 + Widget build(BuildContext context) {
24 + final theme = ShadTheme.of(context);
25 + final colorScheme = theme.colorScheme;
26 +
27 + return PopScope(
28 + canPop: onCancel != null,
29 + onPopInvokedWithResult: (didPop, _) {
30 + if (!didPop && onCancel != null) {
31 + onCancel!();
32 + }
33 + },
34 + child: Material(
35 + color: Colors.black.withValues(alpha: 0.65),
36 + child: BackdropFilter(
37 + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
38 + child: Center(
39 + child: Container(
40 + margin: const EdgeInsets.symmetric(horizontal: 28),
41 + padding: const EdgeInsets.all(24),
42 + decoration: BoxDecoration(
43 + color: colorScheme.card.withValues(alpha: 0.92),
44 + borderRadius: BorderRadius.circular(24),
45 + border: Border.all(
46 + color: colorScheme.border.withValues(alpha: 0.6),
47 + width: 1.2,
48 + ),
49 + boxShadow: [
50 + BoxShadow(
51 + color: Colors.black.withValues(alpha: 0.4),
52 + blurRadius: 32,
53 + offset: const Offset(0, 12),
54 + ),
55 + ],
56 + ),
57 + child: Column(
58 + mainAxisSize: MainAxisSize.min,
59 + children: [
60 + // Animated spinner / pulse
61 + Container(
62 + width: 64,
63 + height: 64,
64 + decoration: BoxDecoration(
65 + color: colorScheme.primary.withValues(alpha: 0.12),
66 + shape: BoxShape.circle,
67 + ),
68 + child: Center(
69 + child: LoadingAnimationWidget.threeArchedCircle(
70 + color: colorScheme.primary,
71 + size: 36,
72 + ),
73 + ),
74 + ),
75 + const SizedBox(height: 20),
76 +
77 + // Title
78 + Text(
79 + 'Preparing Model',
80 + style: theme.textTheme.large.copyWith(
81 + fontWeight: FontWeight.bold,
82 + letterSpacing: -0.2,
83 + ),
84 + ),
85 + const SizedBox(height: 8),
86 +
87 + // Model badge
88 + Container(
89 + padding: const EdgeInsets.symmetric(
90 + horizontal: 12,
91 + vertical: 6,
92 + ),
93 + decoration: BoxDecoration(
94 + color: colorScheme.secondary.withValues(alpha: 0.5),
95 + borderRadius: BorderRadius.circular(10),
96 + border: Border.all(
97 + color: colorScheme.border.withValues(alpha: 0.4),
98 + ),
99 + ),
100 + child: Row(
101 + mainAxisSize: MainAxisSize.min,
102 + children: [
103 + HugeIcon(
104 + icon: HugeIcons.strokeRoundedCpu,
105 + size: 16,
106 + color: colorScheme.primary,
107 + ),
108 + const SizedBox(width: 8),
109 + Flexible(
110 + child: Text(
111 + modelName,
112 + style: theme.textTheme.small.copyWith(
113 + fontWeight: FontWeight.w600,
114 + color: colorScheme.foreground,
115 + ),
116 + maxLines: 1,
117 + overflow: TextOverflow.ellipsis,
118 + ),
119 + ),
120 + ],
121 + ),
122 + ),
123 + if (serverName != null && serverName!.isNotEmpty) ...[
124 + const SizedBox(height: 6),
125 + Text(
126 + serverName!,
127 + style: theme.textTheme.muted.copyWith(fontSize: 12),
128 + ),
129 + ],
130 +
131 + const SizedBox(height: 16),
132 +
133 + // Dynamic status message
134 + Text(
135 + statusMessage,
136 + textAlign: TextAlign.center,
137 + style: theme.textTheme.muted.copyWith(fontSize: 13),
138 + ),
139 +
140 + if (onCancel != null) ...[
141 + const SizedBox(height: 20),
142 + ShadButton.outline(
143 + size: ShadButtonSize.sm,
144 + onPressed: onCancel,
145 + child: const Row(
146 + mainAxisSize: MainAxisSize.min,
147 + children: [
148 + HugeIcon(
149 + icon: HugeIcons.strokeRoundedCancel01,
150 + size: 14,
151 + color: Colors.grey,
152 + ),
153 + SizedBox(width: 6),
154 + Text('Cancel'),
155 + ],
156 + ),
157 + ),
158 + ],
159 + ],
160 + ),
161 + ),
162 + ),
163 + ),
164 + ),
165 + );
166 + }
167 +}
lib/features/os_widget/views/os_widget_invocation_host.dart new
+297
@@ -0,0 +1,297 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter/material.dart';
4 +import 'package:flutter_riverpod/flutter_riverpod.dart';
5 +import 'package:shadcn_ui/shadcn_ui.dart';
6 +
7 +import '../../../core/logger/app_logger.dart';
8 +import '../../../core/models/enums.dart';
9 +import '../../../core/providers/app_providers.dart';
10 +import '../../../core/providers/service_providers.dart';
11 +import '../../chat/providers/chat_providers.dart';
12 +import '../../conversations/providers/conversation_providers.dart' as conv;
13 +import '../../models/data/models/model_info.dart';
14 +import '../../on_device/data/models/on_device_model.dart';
15 +import '../../on_device/providers/on_device_providers.dart';
16 +import '../../servers/providers/server_providers.dart';
17 +import '../../voice_mode/providers/voice_mode_provider.dart';
18 +import '../../voice_mode/views/voice_mode_overlay.dart';
19 +import '../data/models/os_widget_models.dart';
20 +import '../providers/os_widget_providers.dart';
21 +import 'components/model_loading_overlay.dart';
22 +
23 +class OsWidgetInvocationHost extends ConsumerStatefulWidget {
24 + const OsWidgetInvocationHost({required this.child, super.key});
25 +
26 + final Widget child;
27 +
28 + @override
29 + ConsumerState<OsWidgetInvocationHost> createState() =>
30 + _OsWidgetInvocationHostState();
31 +}
32 +
33 +class _OsWidgetInvocationHostState
34 + extends ConsumerState<OsWidgetInvocationHost> {
35 + StreamSubscription<OsWidgetInvocation>? _subscription;
36 +
37 + bool _isLoadingModel = false;
38 + String _loadingModelName = '';
39 + String? _loadingServerName;
40 + String _loadingStatusMessage = 'Loading model into memory...';
41 + bool _isShowingVoiceMode = false;
42 + bool _isCanceled = false;
43 +
44 + @override
45 + void initState() {
46 + super.initState();
47 + final service = ref.read(osWidgetServiceProvider);
48 + if (!service.isSupportedPlatform) return;
49 +
50 + _subscription = service.invocations.listen(_handleInvocation);
51 + WidgetsBinding.instance.addPostFrameCallback((_) {
52 + unawaited(service.initialize());
53 + // Initialize widget snapshot sync
54 + ref.read(osWidgetSnapshotSyncProvider);
55 + });
56 + }
57 +
58 + Future<void> _handleInvocation(OsWidgetInvocation invocation) async {
59 + Log.info('Handling OS Widget invocation: ${invocation.action}');
60 +
61 + switch (invocation.action) {
62 + case OsWidgetAction.voice:
63 + await _handleVoiceInvocation();
64 + break;
65 + case OsWidgetAction.newChat:
66 + await _handleChatInvocation(
67 + prompt: invocation.prompt,
68 + startNewChat: true,
69 + );
70 + break;
71 + case OsWidgetAction.chat:
72 + case OsWidgetAction.unknown:
73 + await _handleChatInvocation(
74 + prompt: invocation.prompt,
75 + startNewChat: false,
76 + );
77 + break;
78 + }
79 + }
80 +
81 + Future<void> _handleVoiceInvocation() async {
82 + if (!mounted || _isShowingVoiceMode) return;
83 + if (ref.read(voiceModeProvider).isActive) return;
84 +
85 + _isShowingVoiceMode = true;
86 + try {
87 + await VoiceModeOverlay.show(context);
88 + } finally {
89 + _isShowingVoiceMode = false;
90 + }
91 + }
92 +
93 + Future<void> _handleChatInvocation({
94 + String? prompt,
95 + required bool startNewChat,
96 + }) async {
97 + if (!mounted) return;
98 +
99 + final settings = ref.read(settingsProvider);
100 + final defaultModelId = settings.defaultModelId;
101 + final defaultModelServerId = settings.defaultModelServerId;
102 +
103 + if (defaultModelId == null ||
104 + defaultModelId.isEmpty ||
105 + defaultModelServerId == null ||
106 + defaultModelServerId.isEmpty) {
107 + if (mounted) {
108 + ShadToaster.of(context).show(
109 + ShadToast(
110 + title: const Text('Default Model Required'),
111 + description: const Text(
112 + 'Please configure a default model in Settings to use Quick Widget.',
113 + ),
114 + ),
115 + );
116 + }
117 + if (prompt != null && prompt.isNotEmpty) {
118 + ref.read(widgetPendingPromptProvider.notifier).setPrompt(prompt);
119 + }
120 + return;
121 + }
122 +
123 + final servers = await ref.read(serversProvider.future);
124 + final defaultServer = servers
125 + .where((s) => s.id == defaultModelServerId)
126 + .firstOrNull;
127 +
128 + if (defaultServer == null) {
129 + if (mounted) {
130 + ShadToaster.of(context).show(
131 + ShadToast.destructive(
132 + title: const Text('Server Not Found'),
133 + description: const Text(
134 + 'The server configured for the default model is no longer available.',
135 + ),
136 + ),
137 + );
138 + }
139 + return;
140 + }
141 +
142 + // Determine model display name
143 + String modelDisplayName = defaultModelId;
144 + if (defaultServer.type == ServerType.onDevice) {
145 + final curated = OnDeviceModel.curatedModels
146 + .where((m) => m.id == defaultModelId)
147 + .firstOrNull;
148 + if (curated != null) {
149 + modelDisplayName = curated.name;
150 + }
151 + } else {
152 + try {
153 + final availableModels = await ref.read(
154 + availableModelsProvider(defaultServer.id).future,
155 + );
156 + final found = availableModels
157 + .where((m) => m.id == defaultModelId)
158 + .firstOrNull;
159 + if (found != null) {
160 + modelDisplayName = found.displayName;
161 + }
162 + } catch (_) {
163 + // Fallback to ID
164 + }
165 + }
166 +
167 + _isCanceled = false;
168 + setState(() {
169 + _isLoadingModel = true;
170 + _loadingModelName = modelDisplayName;
171 + _loadingServerName = defaultServer.name;
172 + _loadingStatusMessage = 'Loading model into memory...';
173 + });
174 +
175 + try {
176 + // 1. Set active server
177 + ref.read(activeServerIdProvider.notifier).setActiveServer(defaultServer);
178 +
179 + // 2. Load model if needed
180 + if (defaultServer.type == ServerType.onDevice) {
181 + final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
182 + final engineState = ref.read(onDeviceEngineProvider);
183 +
184 + if (engineState.loadedModelId != defaultModelId ||
185 + engineState.status != OnDeviceEngineStatus.loaded) {
186 + setState(() {
187 + _loadingStatusMessage = 'Initializing on-device engine...';
188 + });
189 + await engineNotifier.loadModel(
190 + defaultModelId,
191 + settings.preferredBackend,
192 + );
193 + }
194 + } else if (defaultServer.type == ServerType.lmStudio) {
195 + final apiService = ref.read(serverApiServiceProvider);
196 + setState(() {
197 + _loadingStatusMessage = 'Checking LM Studio instance...';
198 + });
199 + final running = await apiService.fetchRunningModels(defaultServer);
200 + if (!running.contains(defaultModelId) && !_isCanceled) {
201 + setState(() {
202 + _loadingStatusMessage = 'Loading model in LM Studio...';
203 + });
204 + if (settings.unloadModelsBeforeLoad) {
205 + final instances = await ref.read(
206 + loadedModelsProvider(defaultServer).future,
207 + );
208 + await apiService.unloadAllInstances(defaultServer, instances);
209 + }
210 + await apiService.loadModelWithInstanceId(
211 + defaultServer,
212 + defaultModelId,
213 + contextLength: ref.read(chatParamsProvider).contextLength,
214 + );
215 + }
216 + }
217 +
218 + if (_isCanceled || !mounted) return;
219 +
220 + // 3. Set selected model
221 + final available = await ref.read(
222 + availableModelsProvider(defaultServer.id).future,
223 + );
224 + final targetModel =
225 + available.where((m) => m.id == defaultModelId).firstOrNull ??
226 + ModelInfo(
227 + id: defaultModelId,
228 + name: modelDisplayName,
229 + serverId: defaultServer.id,
230 + serverType: defaultServer.type,
231 + );
232 + ref.read(selectedModelProvider.notifier).setModel(targetModel);
233 +
234 + // 4. Handle new chat if requested
235 + if (startNewChat) {
236 + ref
237 + .read(conv.activeConversationIdProvider.notifier)
238 + .setActiveConversationId(null);
239 + }
240 +
241 + // 5. Pre-fill prompt if provided
242 + if (prompt != null && prompt.isNotEmpty) {
243 + ref.read(widgetPendingPromptProvider.notifier).setPrompt(prompt);
244 + }
245 + } catch (e) {
246 + Log.error('Error loading default model from widget: $e');
247 + if (mounted) {
248 + ShadToaster.of(context).show(
249 + ShadToast.destructive(
250 + title: const Text('Model Loading Failed'),
251 + description: Text('Failed to load $modelDisplayName: $e'),
252 + ),
253 + );
254 + }
255 + } finally {
256 + if (mounted) {
257 + setState(() {
258 + _isLoadingModel = false;
259 + });
260 + }
261 + }
262 + }
263 +
264 + void _cancelLoading() {
265 + _isCanceled = true;
266 + setState(() {
267 + _isLoadingModel = false;
268 + });
269 + }
270 +
271 + @override
272 + void dispose() {
273 + unawaited(_subscription?.cancel());
274 + super.dispose();
275 + }
276 +
277 + @override
278 + Widget build(BuildContext context) {
279 + // Keep widget snapshot synced with active settings
280 + ref.watch(osWidgetSnapshotSyncProvider);
281 +
282 + return Stack(
283 + children: [
284 + widget.child,
285 + if (_isLoadingModel)
286 + Positioned.fill(
287 + child: ModelLoadingOverlay(
288 + modelName: _loadingModelName,
289 + serverName: _loadingServerName,
290 + statusMessage: _loadingStatusMessage,
291 + onCancel: _cancelLoading,
292 + ),
293 + ),
294 + ],
295 + );
296 + }
297 +}
pubspec.lock
+8
@@ -821,6 +821,14 @@ packages:
821 url: "https://pub.dev"
822 source: hosted
823 version: "0.6.4+3"
824 + home_widget:
825 + dependency: "direct main"
826 + description:
827 + name: home_widget
828 + sha256: fece84c7464099873c3ace38394ad7053509a4b0e36e57a1105a6aa31f47f23c
829 + url: "https://pub.dev"
830 + source: hosted
831 + version: "0.9.3"
832 hooks:
833 dependency: transitive
834 description:
pubspec.yaml
+2 -1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.7.2+77
19 +version: 1.7.3+78
20
21 environment:
22 sdk: ">=3.12.0 <4.0.0"
@@ -88,6 +88,7 @@ dependencies:
88 permission_handler: ^11.4.0
89 auto_size_text_plus: ^3.0.2
90 device_calendar_plus: ^0.8.0
91 + home_widget: ^0.9.3
92
93 dev_dependencies:
94 flutter_test:
test/features/os_widget/model_loading_overlay_test.dart new
+64
@@ -0,0 +1,64 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/features/os_widget/views/components/model_loading_overlay.dart';
4 +import 'package:shadcn_ui/shadcn_ui.dart';
5 +
6 +void main() {
7 + Widget createWidget({
8 + required String modelName,
9 + String? serverName,
10 + String statusMessage = 'Loading model into memory...',
11 + VoidCallback? onCancel,
12 + }) {
13 + return ShadApp(
14 + home: Scaffold(
15 + body: ModelLoadingOverlay(
16 + modelName: modelName,
17 + serverName: serverName,
18 + statusMessage: statusMessage,
19 + onCancel: onCancel,
20 + ),
21 + ),
22 + );
23 + }
24 +
25 + testWidgets('ModelLoadingOverlay renders model info and status correctly', (
26 + tester,
27 + ) async {
28 + await tester.pumpWidget(
29 + createWidget(
30 + modelName: 'Llama-3-8B-Instruct',
31 + serverName: 'Local On-Device Engine',
32 + statusMessage: 'Allocating context memory...',
33 + ),
34 + );
35 + await tester.pump();
36 +
37 + expect(find.text('Preparing Model'), findsOneWidget);
38 + expect(find.text('Llama-3-8B-Instruct'), findsOneWidget);
39 + expect(find.text('Local On-Device Engine'), findsOneWidget);
40 + expect(find.text('Allocating context memory...'), findsOneWidget);
41 + expect(find.text('Cancel'), findsNothing);
42 + });
43 +
44 + testWidgets('ModelLoadingOverlay triggers onCancel when cancel is pressed', (
45 + tester,
46 + ) async {
47 + var canceled = false;
48 + await tester.pumpWidget(
49 + createWidget(
50 + modelName: 'Gemma-2-2B',
51 + onCancel: () {
52 + canceled = true;
53 + },
54 + ),
55 + );
56 + await tester.pump();
57 +
58 + expect(find.text('Cancel'), findsOneWidget);
59 + await tester.tap(find.text('Cancel'));
60 + await tester.pump();
61 +
62 + expect(canceled, isTrue);
63 + });
64 +}
test/features/os_widget/os_widget_invocation_host_test.dart new
+194
@@ -0,0 +1,194 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/core/models/enums.dart';
5 +import 'package:localmind/core/providers/app_providers.dart';
6 +import 'package:localmind/core/providers/storage_providers.dart';
7 +import 'package:shared_preferences/shared_preferences.dart';
8 +import 'package:localmind/features/chat/providers/chat_providers.dart';
9 +import 'package:localmind/features/conversations/providers/conversation_providers.dart'
10 + as conv;
11 +import 'package:localmind/features/models/data/models/model_info.dart';
12 +import 'package:localmind/features/os_widget/data/models/os_widget_models.dart';
13 +import 'package:localmind/features/os_widget/data/os_widget_service.dart';
14 +import 'package:localmind/features/os_widget/providers/os_widget_providers.dart';
15 +import 'package:localmind/features/os_widget/views/os_widget_invocation_host.dart';
16 +import 'package:localmind/features/servers/data/models/server.dart';
17 +import 'package:localmind/features/servers/providers/server_providers.dart';
18 +import 'package:localmind/features/settings/data/models/app_settings.dart';
19 +import 'package:shadcn_ui/shadcn_ui.dart';
20 +
21 +class _TestSettingsNotifier extends SettingsNotifier {
22 + _TestSettingsNotifier({this.customSettings});
23 + final AppSettings? customSettings;
24 +
25 + @override
26 + AppSettings build() =>
27 + customSettings ?? AppSettings(unloadModelsBeforeLoad: false);
28 +}
29 +
30 +class _StubServersNotifier extends ServersNotifier {
31 + _StubServersNotifier([this.servers = const []]);
32 + final List<Server> servers;
33 +
34 + @override
35 + Future<List<Server>> build() async => servers;
36 +}
37 +
38 +void main() {
39 + group('WidgetPendingPromptNotifier', () {
40 + test('sets and consumes prompt', () {
41 + final container = ProviderContainer();
42 + addTearDown(container.dispose);
43 +
44 + expect(container.read(widgetPendingPromptProvider), isNull);
45 +
46 + container
47 + .read(widgetPendingPromptProvider.notifier)
48 + .setPrompt('Draft prompt from widget');
49 + expect(
50 + container.read(widgetPendingPromptProvider),
51 + 'Draft prompt from widget',
52 + );
53 +
54 + final consumed = container
55 + .read(widgetPendingPromptProvider.notifier)
56 + .consumePrompt();
57 + expect(consumed, 'Draft prompt from widget');
58 + expect(container.read(widgetPendingPromptProvider), isNull);
59 + });
60 + });
61 +
62 + group('OsWidgetInvocationHost', () {
63 + testWidgets(
64 + 'renders child and handles widget prompt when default model is unset',
65 + (tester) async {
66 + final service = OsWidgetService(isSupportedOverride: true);
67 +
68 + await tester.pumpWidget(
69 + ProviderScope(
70 + overrides: [
71 + osWidgetServiceProvider.overrideWithValue(service),
72 + settingsProvider.overrideWith(_TestSettingsNotifier.new),
73 + serversProvider.overrideWith(_StubServersNotifier.new),
74 + ],
75 + child: const ShadApp(
76 + home: Scaffold(
77 + body: OsWidgetInvocationHost(child: Text('App Shell Content')),
78 + ),
79 + ),
80 + ),
81 + );
82 + await tester.pumpAndSettle();
83 +
84 + expect(find.text('App Shell Content'), findsOneWidget);
85 +
86 + // Dispatch invocation with prompt
87 + service.dispatchInvocation(
88 + const OsWidgetInvocation(
89 + action: OsWidgetAction.chat,
90 + prompt: 'Quick question about Flutter',
91 + ),
92 + );
93 +
94 + await tester.pump();
95 + await tester.pump(const Duration(milliseconds: 200));
96 +
97 + expect(find.text('App Shell Content'), findsOneWidget);
98 + },
99 + );
100 +
101 + testWidgets(
102 + 'loads default model and sets pending prompt when default model is configured',
103 + (tester) async {
104 + SharedPreferences.setMockInitialValues({});
105 + final prefs = await SharedPreferences.getInstance();
106 +
107 + final service = OsWidgetService(isSupportedOverride: true);
108 + final server = Server(
109 + id: 'ollama-1',
110 + name: 'Local Ollama',
111 + host: 'http://localhost',
112 + port: 11434,
113 + type: ServerType.ollama,
114 + createdAt: DateTime.now(),
115 + lastConnectedAt: DateTime.now(),
116 + );
117 + final model = ModelInfo(
118 + id: 'llama3:8b',
119 + name: 'Llama 3 8B',
120 + serverId: server.id,
121 + serverType: ServerType.ollama,
122 + );
123 +
124 + final testSettings = AppSettings(
125 + defaultModelId: model.id,
126 + defaultModelServerId: server.id,
127 + unloadModelsBeforeLoad: false,
128 + );
129 +
130 + late ProviderContainer container;
131 +
132 + await tester.pumpWidget(
133 + ProviderScope(
134 + overrides: [
135 + sharedPreferencesProvider.overrideWithValue(prefs),
136 + osWidgetServiceProvider.overrideWithValue(service),
137 + settingsProvider.overrideWith(
138 + () => _TestSettingsNotifier(customSettings: testSettings),
139 + ),
140 + serversProvider.overrideWith(
141 + () => _StubServersNotifier([server]),
142 + ),
143 + availableModelsProvider(
144 + server.id,
145 + ).overrideWith((ref) => Future.value([model])),
146 + ],
147 + child: Consumer(
148 + builder: (context, ref, _) {
149 + container = ProviderScope.containerOf(context);
150 + return const ShadApp(
151 + home: Scaffold(
152 + body: OsWidgetInvocationHost(
153 + child: Text('Chat Screen Body'),
154 + ),
155 + ),
156 + );
157 + },
158 + ),
159 + ),
160 + );
161 + await tester.pumpAndSettle();
162 +
163 + expect(find.text('Chat Screen Body'), findsOneWidget);
164 +
165 + // Dispatch chat invocation with prompt
166 + service.dispatchInvocation(
167 + const OsWidgetInvocation(
168 + action: OsWidgetAction.newChat,
169 + prompt: 'Summarize today meetings',
170 + ),
171 + );
172 +
173 + await tester.pump();
174 + await tester.pumpAndSettle();
175 +
176 + // Model is selected
177 + final selected = container.read(selectedModelProvider);
178 + expect(selected?.id, 'llama3:8b');
179 +
180 + // Active server is set
181 + final activeServer = container.read(activeServerProvider);
182 + expect(activeServer?.id, 'ollama-1');
183 +
184 + // Pending prompt is populated
185 + final pendingPrompt = container.read(widgetPendingPromptProvider);
186 + expect(pendingPrompt, 'Summarize today meetings');
187 +
188 + // Active conversation was cleared for new chat
189 + final activeConvId = container.read(conv.activeConversationIdProvider);
190 + expect(activeConvId, isNull);
191 + },
192 + );
193 + });
194 +}
test/features/os_widget/os_widget_service_test.dart new
+77
@@ -0,0 +1,77 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/os_widget/data/models/os_widget_models.dart';
3 +import 'package:localmind/features/os_widget/data/os_widget_service.dart';
4 +
5 +void main() {
6 + group('OsWidgetInvocation', () {
7 + test('parses chat action with prompt', () {
8 + final uri = Uri.parse('localmind://widget?action=chat&prompt=Hello%20AI');
9 + final invocation = OsWidgetInvocation.fromUri(uri);
10 +
11 + expect(invocation.action, OsWidgetAction.chat);
12 + expect(invocation.prompt, 'Hello AI');
13 + expect(invocation.uri, uri);
14 + });
15 +
16 + test('parses text query parameter as prompt fallback', () {
17 + final uri = Uri.parse(
18 + 'localmind://widget?action=chat&text=Explain%20quantum',
19 + );
20 + final invocation = OsWidgetInvocation.fromUri(uri);
21 +
22 + expect(invocation.action, OsWidgetAction.chat);
23 + expect(invocation.prompt, 'Explain quantum');
24 + });
25 +
26 + test('parses new_chat action', () {
27 + final uri = Uri.parse('localmind://widget?action=new_chat');
28 + final invocation = OsWidgetInvocation.fromUri(uri);
29 +
30 + expect(invocation.action, OsWidgetAction.newChat);
31 + expect(invocation.prompt, isNull);
32 + });
33 +
34 + test('parses voice action', () {
35 + final uri = Uri.parse('localmind://widget?action=voice');
36 + final invocation = OsWidgetInvocation.fromUri(uri);
37 +
38 + expect(invocation.action, OsWidgetAction.voice);
39 + expect(invocation.prompt, isNull);
40 + });
41 +
42 + test('defaults to chat action for unknown or unspecified action', () {
43 + final uri = Uri.parse('localmind://widget');
44 + final invocation = OsWidgetInvocation.fromUri(uri);
45 +
46 + expect(invocation.action, OsWidgetAction.chat);
47 + expect(invocation.prompt, isNull);
48 + });
49 + });
50 +
51 + group('OsWidgetService', () {
52 + test('dispatches invocations through stream', () async {
53 + final service = OsWidgetService(isSupportedOverride: true);
54 + final emitted = <OsWidgetInvocation>[];
55 + final sub = service.invocations.listen(emitted.add);
56 +
57 + final inv1 = OsWidgetInvocation(
58 + action: OsWidgetAction.chat,
59 + prompt: 'Test Prompt',
60 + );
61 + final inv2 = OsWidgetInvocation(action: OsWidgetAction.voice);
62 +
63 + service.dispatchInvocation(inv1);
64 + service.dispatchInvocation(inv2);
65 +
66 + await Future<void>.delayed(Duration.zero);
67 +
68 + expect(emitted.length, 2);
69 + expect(emitted[0].action, OsWidgetAction.chat);
70 + expect(emitted[0].prompt, 'Test Prompt');
71 + expect(emitted[1].action, OsWidgetAction.voice);
72 +
73 + await sub.cancel();
74 + await service.dispose();
75 + });
76 + });
77 +}