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
+}