41 files changed
+2914
-33
android/.project
new
+28
@@ -0,0 +1,28 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<projectDescription>
3
+ <name>android</name>
4
+ <comment>Project android created by Buildship.</comment>
5
+ <projects>
6
+ </projects>
7
+ <buildSpec>
8
+ <buildCommand>
9
+ <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
10
+ <arguments>
11
+ </arguments>
12
+ </buildCommand>
13
+ </buildSpec>
14
+ <natures>
15
+ <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
16
+ </natures>
17
+ <filteredResources>
18
+ <filter>
19
+ <id>1776320082278</id>
20
+ <name></name>
21
+ <type>30</type>
22
+ <matcher>
23
+ <id>org.eclipse.core.resources.regexFilterMatcher</id>
24
+ <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
25
+ </matcher>
26
+ </filter>
27
+ </filteredResources>
28
+</projectDescription>
android/.settings/org.eclipse.buildship.core.prefs
new
+13
@@ -0,0 +1,13 @@
1
+arguments=--init-script /Users/abdulmominsakib/.cache/opencode/bin/jdtls/config_mac/org.eclipse.osgi/59/0/.cp/gradle/init/init.gradle
2
+auto.sync=false
3
+build.scans.enabled=false
4
+connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
5
+connection.project.dir=
6
+eclipse.preferences.version=1
7
+gradle.user.home=
8
+java.home=/opt/homebrew/Cellar/openjdk@21/21.0.10/libexec/openjdk.jdk/Contents/Home
9
+jvm.arguments=
10
+offline.mode=false
11
+override.workspace.settings=true
12
+show.console.view=true
13
+show.executions.view=true
android/app/.classpath
new
+6
@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<classpath>
3
+ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
4
+ <classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
5
+ <classpathentry kind="output" path="bin/default"/>
6
+</classpath>
android/app/.project
new
+34
@@ -0,0 +1,34 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<projectDescription>
3
+ <name>app</name>
4
+ <comment>Project app created by Buildship.</comment>
5
+ <projects>
6
+ </projects>
7
+ <buildSpec>
8
+ <buildCommand>
9
+ <name>org.eclipse.jdt.core.javabuilder</name>
10
+ <arguments>
11
+ </arguments>
12
+ </buildCommand>
13
+ <buildCommand>
14
+ <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
15
+ <arguments>
16
+ </arguments>
17
+ </buildCommand>
18
+ </buildSpec>
19
+ <natures>
20
+ <nature>org.eclipse.jdt.core.javanature</nature>
21
+ <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
22
+ </natures>
23
+ <filteredResources>
24
+ <filter>
25
+ <id>1776320082280</id>
26
+ <name></name>
27
+ <type>30</type>
28
+ <matcher>
29
+ <id>org.eclipse.core.resources.regexFilterMatcher</id>
30
+ <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
31
+ </matcher>
32
+ </filter>
33
+ </filteredResources>
34
+</projectDescription>
android/app/.settings/org.eclipse.buildship.core.prefs
new
+2
@@ -0,0 +1,2 @@
1
+connection.project.dir=..
2
+eclipse.preferences.version=1
android/app/build.gradle.kts
+7
-1
@@ -20,6 +20,7 @@ android {
20
ndkVersion = flutter.ndkVersion
21
22
compileOptions {
23
+ isCoreLibraryDesugaringEnabled = true
24
sourceCompatibility = JavaVersion.VERSION_17
25
targetCompatibility = JavaVersion.VERSION_17
26
}
@@ -33,10 +34,11 @@ android {
34
applicationId = "pro.momin.localmind"
35
// You can update the following values to match your application needs.
36
// For more information, see: https://flutter.dev/to/review-gradle-config.
36
- minSdk = flutter.minSdkVersion
37
+ minSdk = 26
38
targetSdk = flutter.targetSdkVersion
39
versionCode = flutter.versionCode
40
versionName = flutter.versionName
41
+ multiDexEnabled = true
42
}
43
44
signingConfigs {
@@ -64,3 +66,7 @@ android {
66
flutter {
67
source = "../.."
68
}
69
+
70
+dependencies {
71
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
72
+}
android/app/src/main/AndroidManifest.xml
+3
-1
@@ -1,5 +1,7 @@
1
-<manifest xmlns:android="http://schemas.android.com/apk/res/android">
1
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ xmlns:tools="http://schemas.android.com/tools">
3
<uses-permission android:name="android.permission.INTERNET" />
4
+
5
<application
6
android:label="LocalMind"
7
android:name="${applicationName}"
ios/Runner/AppDelegate.swift
-4
@@ -9,8 +9,4 @@ import UIKit
9
) -> Bool {
10
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
11
}
12
-
13
- func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
14
- GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
15
- }
12
}
ios/Runner/Info.plist
+5
@@ -73,5 +73,10 @@
73
<string>UIInterfaceOrientationLandscapeLeft</string>
74
<string>UIInterfaceOrientationLandscapeRight</string>
75
</array>
76
+ <key>UIBackgroundModes</key>
77
+ <array>
78
+ <string>fetch</string>
79
+ <string>processing</string>
80
+ </array>
81
</dict>
82
</plist>
lib/app.dart
+15
@@ -9,12 +9,15 @@ import 'core/routes/app_routes.dart';
9
import 'core/theme/app_theme.dart';
10
import 'features/chat/views/chat_screen.dart';
11
import 'features/conversations/views/chat_history_screen.dart';
12
+import 'features/on_device/views/model_manager_screen.dart';
13
+import 'features/onboarding/screens/onboarding_model_download_screen.dart';
14
import 'features/onboarding/screens/onboarding_server_setup_screen.dart';
15
import 'features/onboarding/screens/onboarding_server_type_screen.dart';
16
import 'features/onboarding/screens/onboarding_theme_screen.dart';
17
import 'features/personas/views/create_persona_screen.dart';
18
import 'features/personas/views/persona_list_screen.dart';
19
import 'features/servers/data/models/server.dart';
20
+import 'features/servers/providers/server_providers.dart';
21
import 'features/servers/views/add_server_screen.dart';
22
import 'features/servers/views/server_list_screen.dart';
23
import 'features/settings/screens/settings_screen.dart';
@@ -63,6 +66,11 @@ final routerProvider = Provider<GoRouter>((ref) {
66
);
67
},
68
),
69
+ GoRoute(
70
+ path: AppRoutes.onboardingModelDownload,
71
+ pageBuilder: (context, state) =>
72
+ const MaterialPage(child: OnboardingModelDownloadScreen()),
73
+ ),
74
GoRoute(
75
path: AppRoutes.onboardingTheme,
76
pageBuilder: (context, state) =>
@@ -115,6 +123,11 @@ final routerProvider = Provider<GoRouter>((ref) {
123
pageBuilder: (context, state) =>
124
const NoTransitionPage(child: ChatHistoryScreen()),
125
),
126
+ GoRoute(
127
+ path: AppRoutes.onDeviceModels,
128
+ pageBuilder: (context, state) =>
129
+ const NoTransitionPage(child: OnDeviceModelManagerScreen()),
130
+ ),
131
],
132
),
133
],
@@ -129,6 +142,8 @@ class App extends ConsumerWidget {
142
final router = ref.watch(routerProvider);
143
final appThemeType = ref.watch(themeModeProvider);
144
145
+ ref.watch(ensureOnDeviceServerProvider);
146
+
147
ThemeData theme = AppTheme.lightTheme;
148
ThemeData darkTheme = AppTheme.darkTheme;
149
ThemeMode themeMode = ThemeMode.system;
lib/core/models/enums.dart
+14
-3
@@ -1,6 +1,4 @@
1
-
2
-
3
-enum ServerType { lmStudio, openAICompatible, ollama, openRouter }
1
+enum ServerType { lmStudio, openAICompatible, ollama, openRouter, onDevice }
2
3
enum ConnectionStatus { connected, disconnected, checking, error }
4
@@ -9,3 +7,16 @@ enum MessageRole { user, assistant, system, tool }
7
enum MessageStatus { sending, streaming, complete, error }
8
9
enum ModelStatus { unloaded, loading, loaded, preloaded, thinking }
10
+
11
+enum EngineStatus { notLoaded, loading, loaded, error }
12
+
13
+enum LiteLmBackendType { cpu, gpu, npu }
14
+
15
+enum OnDeviceModelState {
16
+ notDownloaded,
17
+ downloading,
18
+ downloaded,
19
+ loading,
20
+ loaded,
21
+ error,
22
+}
lib/core/providers/app_providers.dart
+3
@@ -1,5 +1,6 @@
1
import 'package:flutter_riverpod/flutter_riverpod.dart';
2
import '../../features/settings/data/models/app_settings.dart';
3
+import '../models/enums.dart';
4
import 'storage_providers.dart';
5
import '../theme/app_theme.dart';
6
@@ -80,6 +81,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
81
_update(state.copyWith(codeThemeDark: value));
82
void setCodeThemeLight(SyntaxThemeName value) =>
83
_update(state.copyWith(codeThemeLight: value));
84
+ void setPreferredBackend(LiteLmBackendType value) =>
85
+ _update(state.copyWith(preferredBackend: value));
86
87
Future<void> _update(AppSettings updated) async {
88
state = updated;
lib/core/routes/app_routes.dart
+2
-1
@@ -2,6 +2,7 @@ class AppRoutes {
2
static const String home = '/';
3
static const String onboarding = '/onboarding';
4
static const String onboardingSetup = '/onboarding/setup';
5
+ static const String onboardingModelDownload = '/onboarding/model-download';
6
static const String onboardingTheme = '/onboarding/theme';
7
static const String servers = '/servers';
8
static const String addServer = '/servers/add';
@@ -10,5 +11,5 @@ class AppRoutes {
11
static const String settings = '/settings';
12
static const String models = '/models';
13
static const String chatHistory = '/history';
14
+ static const String onDeviceModels = '/on-device-models';
15
}
14
-
lib/features/chat/data/chat_service.dart
+14
-1
@@ -7,6 +7,8 @@ import 'models/chat_parameters.dart';
7
import 'models/mcp_integration.dart';
8
import '../../../core/models/enums.dart';
9
import '../../../core/logger/app_logger.dart';
10
+import '../../on_device/data/on_device_engine_service.dart';
11
+import '../../on_device/data/on_device_chat_service.dart';
12
13
abstract class ChatService {
14
Stream<ChatResponse> sendMessage({
@@ -20,7 +22,11 @@ abstract class ChatService {
22
23
void cancelStream();
24
23
- static ChatService forServer(ServerType type, Dio dio) {
25
+ static ChatService forServer(
26
+ ServerType type,
27
+ Dio dio, {
28
+ OnDeviceEngineService? onDeviceEngine,
29
+ }) {
30
switch (type) {
31
case ServerType.lmStudio:
32
return LMStudioChatService(dio);
@@ -30,6 +36,13 @@ abstract class ChatService {
36
return OllamaChatService(dio);
37
case ServerType.openRouter:
38
return OpenRouterChatService(dio);
39
+ case ServerType.onDevice:
40
+ if (onDeviceEngine == null) {
41
+ throw StateError(
42
+ 'OnDeviceEngineService is required for onDevice server type',
43
+ );
44
+ }
45
+ return OnDeviceChatService(onDeviceEngine);
46
}
47
}
48
}
lib/features/chat/providers/chat_providers.dart
+9
@@ -15,6 +15,7 @@ import 'package:localmind/features/servers/providers/server_providers.dart';
15
import '../../../core/models/enums.dart';
16
import '../../../core/providers/service_providers.dart';
17
import '../../../core/providers/storage_providers.dart';
18
+import '../../on_device/providers/on_device_providers.dart';
19
import '../../../core/storage/entities.dart';
20
import '../../../objectbox.g.dart';
21
import '../../conversations/data/models/conversation.dart';
@@ -208,6 +209,14 @@ final chatServiceProvider = Provider<ChatService?>((ref) {
209
if (server == null) {
210
return null;
211
}
212
+ if (server.type == ServerType.onDevice) {
213
+ final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
214
+ return ChatService.forServer(
215
+ server.type,
216
+ ref.read(dioProvider),
217
+ onDeviceEngine: engineNotifier.engineService,
218
+ );
219
+ }
220
return ChatService.forServer(server.type, ref.read(dioProvider));
221
});
222
lib/features/models/screens/model_picker_sheet.dart
+413
@@ -3,8 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import '../../chat/providers/chat_providers.dart';
4
import '../data/models/model_info.dart';
5
import '../../servers/providers/server_providers.dart';
6
+import '../../../core/providers/app_providers.dart';
7
import '../../../core/providers/service_providers.dart';
8
import '../../../core/models/enums.dart';
9
+import '../../on_device/data/models/on_device_model.dart';
10
+import '../../on_device/data/models/download_status.dart';
11
+import '../../on_device/providers/on_device_providers.dart';
12
+import '../../on_device/providers/foreground_download_providers.dart';
13
14
final modelSearchQueryProvider = NotifierProvider<_ModelSearchNotifier, String>(
15
_ModelSearchNotifier.new,
@@ -220,6 +225,17 @@ class _ModelList extends ConsumerWidget {
225
226
@override
227
Widget build(BuildContext context, WidgetRef ref) {
228
+ final serversAsync = ref.watch(serversProvider);
229
+ final servers = serversAsync.value ?? [];
230
+ final currentServer = servers.where((s) => s.id == serverId).firstOrNull;
231
+
232
+ if (currentServer != null && currentServer.type == ServerType.onDevice) {
233
+ return _OnDeviceModelList(
234
+ selectedModelId: selectedModelId,
235
+ isDark: isDark,
236
+ );
237
+ }
238
+
239
final modelsAsync = ref.watch(availableModelsProvider(serverId));
240
241
return modelsAsync.when(
@@ -617,3 +633,400 @@ class _MetadataChip extends StatelessWidget {
633
);
634
}
635
}
636
+
637
+class _OnDeviceModelList extends ConsumerWidget {
638
+ const _OnDeviceModelList({
639
+ required this.selectedModelId,
640
+ required this.isDark,
641
+ });
642
+
643
+ final String? selectedModelId;
644
+ final bool isDark;
645
+
646
+ @override
647
+ Widget build(BuildContext context, WidgetRef ref) {
648
+ final curatedModels = ref.watch(onDeviceModelsProvider);
649
+ final downloadedAsync = ref.watch(downloadedModelsProvider);
650
+ final engineState = ref.watch(onDeviceEngineProvider);
651
+ final searchQuery = ref.watch(modelSearchQueryProvider);
652
+
653
+ return downloadedAsync.when(
654
+ loading: () => const Center(child: CircularProgressIndicator()),
655
+ error: (err, _) => Center(
656
+ child: Column(
657
+ mainAxisAlignment: MainAxisAlignment.center,
658
+ children: [
659
+ Icon(Icons.error_outline, size: 48, color: Colors.red[400]),
660
+ const SizedBox(height: 16),
661
+ Text(
662
+ 'Error loading models',
663
+ style: TextStyle(
664
+ fontSize: 16,
665
+ fontWeight: FontWeight.w500,
666
+ color: isDark ? Colors.white : Colors.black,
667
+ ),
668
+ ),
669
+ const SizedBox(height: 16),
670
+ OutlinedButton.icon(
671
+ onPressed: () => ref.invalidate(downloadedModelsProvider),
672
+ icon: const Icon(Icons.refresh),
673
+ label: const Text('Retry'),
674
+ ),
675
+ ],
676
+ ),
677
+ ),
678
+ data: (downloadedIds) {
679
+ final filtered = searchQuery.isEmpty
680
+ ? curatedModels
681
+ : curatedModels
682
+ .where(
683
+ (m) => m.name.toLowerCase().contains(
684
+ searchQuery.toLowerCase(),
685
+ ),
686
+ )
687
+ .toList();
688
+
689
+ if (filtered.isEmpty) {
690
+ return Center(
691
+ child: Text(
692
+ searchQuery.isEmpty
693
+ ? 'No models available'
694
+ : 'No models match "$searchQuery"',
695
+ style: TextStyle(
696
+ fontSize: 14,
697
+ color: isDark
698
+ ? const Color(0xFF888888)
699
+ : const Color(0xFF999999),
700
+ ),
701
+ ),
702
+ );
703
+ }
704
+
705
+ return ListView.builder(
706
+ itemCount: filtered.length,
707
+ itemBuilder: (context, index) {
708
+ final model = filtered[index];
709
+ final isDownloaded = downloadedIds.contains(model.id);
710
+ final isLoaded = engineState.loadedModelId == model.id;
711
+ final isCurrentlyLoading =
712
+ engineState.status == EngineStatus.loading &&
713
+ engineState.loadedModelId == model.id;
714
+ final isSelected = selectedModelId == model.id;
715
+
716
+ return _OnDeviceModelTile(
717
+ model: model,
718
+ isDownloaded: isDownloaded,
719
+ isLoaded: isLoaded,
720
+ isCurrentlyLoading: isCurrentlyLoading,
721
+ isSelected: isSelected,
722
+ isDark: isDark,
723
+ );
724
+ },
725
+ );
726
+ },
727
+ );
728
+ }
729
+}
730
+
731
+class _OnDeviceModelTile extends ConsumerWidget {
732
+ const _OnDeviceModelTile({
733
+ required this.model,
734
+ required this.isDownloaded,
735
+ required this.isLoaded,
736
+ required this.isCurrentlyLoading,
737
+ required this.isSelected,
738
+ required this.isDark,
739
+ });
740
+
741
+ final OnDeviceModel model;
742
+ final bool isDownloaded;
743
+ final bool isLoaded;
744
+ final bool isCurrentlyLoading;
745
+ final bool isSelected;
746
+ final bool isDark;
747
+
748
+ @override
749
+ Widget build(BuildContext context, WidgetRef ref) {
750
+ final accent = isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB);
751
+ final downloadProgress = ref.watch(
752
+ foregroundDownloadNotifierProvider,
753
+ )[model.id];
754
+ final isDownloading =
755
+ downloadProgress != null &&
756
+ (downloadProgress.status == DownloadStatus.running ||
757
+ downloadProgress.status == DownloadStatus.pending);
758
+ final isPaused = downloadProgress?.status == DownloadStatus.paused;
759
+
760
+ return Container(
761
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
762
+ margin: const EdgeInsets.only(bottom: 4),
763
+ decoration: BoxDecoration(
764
+ color: isSelected ? accent.withValues(alpha: 0.1) : Colors.transparent,
765
+ borderRadius: BorderRadius.circular(8),
766
+ border: isSelected
767
+ ? Border.all(color: accent.withValues(alpha: 0.3))
768
+ : null,
769
+ ),
770
+ child: Row(
771
+ children: [
772
+ Expanded(
773
+ child: Column(
774
+ crossAxisAlignment: CrossAxisAlignment.start,
775
+ children: [
776
+ Row(
777
+ children: [
778
+ Expanded(
779
+ child: Text(
780
+ model.name,
781
+ style: TextStyle(
782
+ fontSize: 15,
783
+ fontWeight: isSelected
784
+ ? FontWeight.w600
785
+ : FontWeight.w500,
786
+ color: isDownloaded
787
+ ? (isDark ? Colors.white : Colors.black)
788
+ : (isDark
789
+ ? const Color(0xFF555555)
790
+ : const Color(0xFF999999)),
791
+ ),
792
+ maxLines: 1,
793
+ overflow: TextOverflow.ellipsis,
794
+ ),
795
+ ),
796
+ if (model.isRecommended)
797
+ Container(
798
+ padding: const EdgeInsets.symmetric(
799
+ horizontal: 6,
800
+ vertical: 2,
801
+ ),
802
+ decoration: BoxDecoration(
803
+ color: accent.withValues(alpha: 0.1),
804
+ borderRadius: BorderRadius.circular(4),
805
+ ),
806
+ child: Text(
807
+ 'REC',
808
+ style: TextStyle(
809
+ fontSize: 9,
810
+ fontWeight: FontWeight.bold,
811
+ color: accent,
812
+ ),
813
+ ),
814
+ ),
815
+ ],
816
+ ),
817
+ if (isDownloading || isPaused)
818
+ Padding(
819
+ padding: const EdgeInsets.only(top: 6),
820
+ child: Column(
821
+ crossAxisAlignment: CrossAxisAlignment.start,
822
+ children: [
823
+ LinearProgressIndicator(
824
+ value: downloadProgress?.progress ?? 0.0,
825
+ backgroundColor: isDark
826
+ ? const Color(0xFF333333)
827
+ : const Color(0xFFE0E0E0),
828
+ ),
829
+ const SizedBox(height: 2),
830
+ Text(
831
+ isPaused
832
+ ? 'Paused - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%'
833
+ : 'Downloading - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
834
+ style: TextStyle(
835
+ fontSize: 10,
836
+ color: isDark
837
+ ? const Color(0xFF888888)
838
+ : const Color(0xFF999999),
839
+ ),
840
+ ),
841
+ ],
842
+ ),
843
+ ),
844
+ const SizedBox(height: 4),
845
+ Wrap(
846
+ spacing: 6,
847
+ runSpacing: 4,
848
+ children: [
849
+ _MetadataChip(
850
+ label: model.fileSizeFormatted,
851
+ isDark: isDark,
852
+ ),
853
+ _MetadataChip(label: model.license, isDark: isDark),
854
+ _MetadataChip(label: model.parameterLabel, isDark: isDark),
855
+ ],
856
+ ),
857
+ ],
858
+ ),
859
+ ),
860
+ const SizedBox(width: 8),
861
+ if (isCurrentlyLoading) ...[
862
+ const SizedBox(
863
+ width: 16,
864
+ height: 16,
865
+ child: CircularProgressIndicator(strokeWidth: 2),
866
+ ),
867
+ ] else if (isLoaded) ...[
868
+ Container(
869
+ width: 10,
870
+ height: 10,
871
+ decoration: const BoxDecoration(
872
+ color: Color(0xFF4CAF50),
873
+ shape: BoxShape.circle,
874
+ ),
875
+ ),
876
+ const SizedBox(width: 8),
877
+ _IconButton(
878
+ icon: Icon(
879
+ Icons.power_settings_new_outlined,
880
+ size: 18,
881
+ color: Colors.red[400],
882
+ ),
883
+ tooltip: 'Unload model',
884
+ onPressed: () => _unloadModel(context, ref),
885
+ ),
886
+ ] else if (isDownloaded) ...[
887
+ _IconButton(
888
+ icon: Icon(Icons.play_arrow, size: 20, color: accent),
889
+ tooltip: 'Load model',
890
+ onPressed: () => _loadModel(context, ref),
891
+ ),
892
+ const SizedBox(width: 4),
893
+ _IconButton(
894
+ icon: Icon(
895
+ Icons.delete_outline,
896
+ size: 18,
897
+ color: isDark
898
+ ? const Color(0xFF666666)
899
+ : const Color(0xFF999999),
900
+ ),
901
+ tooltip: 'Delete model',
902
+ onPressed: () => _deleteModel(context, ref),
903
+ ),
904
+ ] else if (isDownloading) ...[
905
+ _IconButton(
906
+ icon: Icon(
907
+ Icons.close,
908
+ size: 16,
909
+ color: isDark
910
+ ? const Color(0xFF666666)
911
+ : const Color(0xFF999999),
912
+ ),
913
+ tooltip: 'Cancel download',
914
+ onPressed: () => ref
915
+ .read(foregroundDownloadNotifierProvider.notifier)
916
+ .cancelDownload(model.id),
917
+ ),
918
+ ] else ...[
919
+ Icon(
920
+ Icons.cloud_download_outlined,
921
+ size: 18,
922
+ color: isDark ? const Color(0xFF555555) : const Color(0xFF999999),
923
+ ),
924
+ const SizedBox(width: 4),
925
+ Text(
926
+ 'Not downloaded',
927
+ style: TextStyle(
928
+ fontSize: 11,
929
+ color: isDark
930
+ ? const Color(0xFF666666)
931
+ : const Color(0xFF999999),
932
+ ),
933
+ ),
934
+ ],
935
+ if (isDownloaded && isSelected && isLoaded) ...[
936
+ const SizedBox(width: 4),
937
+ Icon(Icons.check_circle, color: accent, size: 22),
938
+ ],
939
+ ],
940
+ ),
941
+ );
942
+ }
943
+
944
+ void _loadModel(BuildContext context, WidgetRef ref) async {
945
+ final settings = ref.read(settingsProvider);
946
+ final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
947
+
948
+ final modelInfo = ModelInfo(
949
+ id: model.id,
950
+ name: model.name,
951
+ description: model.description,
952
+ parameterCount: int.tryParse(
953
+ model.parameterLabel.replaceAll(RegExp(r'[^0-9]'), ''),
954
+ ),
955
+ serverType: ServerType.onDevice,
956
+ serverId: '',
957
+ );
958
+
959
+ await engineNotifier.loadModel(model.id, settings.preferredBackend);
960
+
961
+ final engineState = ref.read(onDeviceEngineProvider);
962
+ if (engineState.loadedModelId == model.id) {
963
+ ref.read(selectedModelProvider.notifier).setModel(modelInfo);
964
+ }
965
+ }
966
+
967
+ void _unloadModel(BuildContext context, WidgetRef ref) async {
968
+ final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
969
+ await engineNotifier.unloadModel();
970
+
971
+ final selectedModel = ref.read(selectedModelProvider);
972
+ if (selectedModel?.id == model.id) {
973
+ ref.read(selectedModelProvider.notifier).clear();
974
+ }
975
+ }
976
+
977
+ void _deleteModel(BuildContext context, WidgetRef ref) async {
978
+ final confirm = await showDialog<bool>(
979
+ context: context,
980
+ builder: (ctx) => AlertDialog(
981
+ title: const Text('Delete Model'),
982
+ content: Text('Are you sure you want to delete ${model.name}?'),
983
+ actions: [
984
+ TextButton(
985
+ onPressed: () => Navigator.pop(ctx, false),
986
+ child: const Text('Cancel'),
987
+ ),
988
+ TextButton(
989
+ onPressed: () => Navigator.pop(ctx, true),
990
+ child: const Text('Delete'),
991
+ ),
992
+ ],
993
+ ),
994
+ );
995
+
996
+ if (confirm == true) {
997
+ final engineState = ref.read(onDeviceEngineProvider);
998
+ if (engineState.loadedModelId == model.id) {
999
+ await ref.read(onDeviceEngineProvider.notifier).unloadModel();
1000
+ ref.read(selectedModelProvider.notifier).clear();
1001
+ }
1002
+
1003
+ final downloadService = ref.read(onDeviceDownloadServiceProvider);
1004
+ await downloadService.deleteModel(model.id);
1005
+ ref.invalidate(downloadedModelsProvider);
1006
+ }
1007
+ }
1008
+}
1009
+
1010
+class _IconButton extends StatelessWidget {
1011
+ const _IconButton({
1012
+ required this.icon,
1013
+ required this.tooltip,
1014
+ required this.onPressed,
1015
+ });
1016
+
1017
+ final Widget icon;
1018
+ final String tooltip;
1019
+ final VoidCallback onPressed;
1020
+
1021
+ @override
1022
+ Widget build(BuildContext context) {
1023
+ return Tooltip(
1024
+ message: tooltip,
1025
+ child: InkWell(
1026
+ onTap: onPressed,
1027
+ borderRadius: BorderRadius.circular(4),
1028
+ child: Padding(padding: const EdgeInsets.all(4), child: icon),
1029
+ ),
1030
+ );
1031
+ }
1032
+}
lib/features/on_device/data/foreground_download_service.dart
new
+161
@@ -0,0 +1,161 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import '../../../core/logger/app_logger.dart';
5
+import 'models/download_status.dart';
6
+import 'models/on_device_model.dart';
7
+import 'on_device_engine_service.dart';
8
+import 'on_device_model_download_service.dart';
9
+
10
+/// Wraps [OnDeviceModelDownloadService] with broadcast streams for UI consumption.
11
+///
12
+/// Progress updates are emitted as [DownloadStatusUpdate] via [getStatusStream].
13
+class ForegroundDownloadService {
14
+ final OnDeviceModelDownloadService _downloadService;
15
+ final Map<String, StreamSubscription> _subscriptions = {};
16
+ final Map<String, StreamController<DownloadStatusUpdate>>
17
+ _progressControllers = {};
18
+
19
+ ForegroundDownloadService(this._downloadService);
20
+
21
+ /// Returns a broadcast stream of status updates for [modelId].
22
+ /// Creates the stream controller if one doesn't exist yet.
23
+ Stream<DownloadStatusUpdate> getStatusStream(String modelId) {
24
+ return _progressControllers
25
+ .putIfAbsent(
26
+ modelId,
27
+ () => StreamController<DownloadStatusUpdate>.broadcast(),
28
+ )
29
+ .stream;
30
+ }
31
+
32
+ /// Start downloading [model]. Progress is emitted via [getStatusStream].
33
+ ///
34
+ /// Call [getStatusStream] before this method to subscribe to progress updates.
35
+ /// Returns the absolute path to the downloaded file on success.
36
+ Future<String> downloadModel(OnDeviceModel model, {String? token}) async {
37
+ final modelsDir = await OnDeviceEngineService.getModelDirectory();
38
+ final filePath = '$modelsDir/${model.fileName}';
39
+ final file = File(filePath);
40
+
41
+ if (await file.exists()) {
42
+ Log.info('Model ${model.id} already downloaded at $filePath');
43
+ // Signal already downloaded via the stream
44
+ final controller = _progressControllers[model.id];
45
+ if (controller != null && !controller.isClosed) {
46
+ controller.add(
47
+ DownloadStatusUpdate(
48
+ modelId: model.id,
49
+ taskId: model.id,
50
+ status: DownloadStatus.complete,
51
+ progress: 100,
52
+ ),
53
+ );
54
+ await _cleanup(model.id);
55
+ }
56
+ return filePath;
57
+ }
58
+
59
+ // Ensure the stream controller exists (created by getStatusStream)
60
+ final controller = _progressControllers.putIfAbsent(
61
+ model.id,
62
+ () => StreamController<DownloadStatusUpdate>.broadcast(),
63
+ );
64
+
65
+ // Emit initial pending status
66
+ if (!controller.isClosed) {
67
+ controller.add(
68
+ DownloadStatusUpdate(
69
+ modelId: model.id,
70
+ taskId: model.id,
71
+ status: DownloadStatus.pending,
72
+ progress: 0,
73
+ ),
74
+ );
75
+ }
76
+
77
+ // Listen to the underlying download stream
78
+ final completer = Completer<String>();
79
+
80
+ final subscription = _downloadService
81
+ .downloadModel(model, token: token)
82
+ .listen(
83
+ (progress) {
84
+ if (controller.isClosed) return;
85
+ final percent = progress.total > 0
86
+ ? (progress.received / progress.total * 100).round()
87
+ : 0;
88
+ controller.add(
89
+ DownloadStatusUpdate(
90
+ modelId: model.id,
91
+ taskId: model.id,
92
+ status: DownloadStatus.running,
93
+ progress: percent,
94
+ ),
95
+ );
96
+ },
97
+ onError: (Object e) {
98
+ if (!controller.isClosed) {
99
+ controller.add(
100
+ DownloadStatusUpdate(
101
+ modelId: model.id,
102
+ taskId: model.id,
103
+ status: DownloadStatus.failed,
104
+ progress: 0,
105
+ ),
106
+ );
107
+ }
108
+ _cleanup(model.id);
109
+ completer.completeError(e);
110
+ },
111
+ onDone: () {
112
+ if (!completer.isCompleted) {
113
+ if (!controller.isClosed) {
114
+ controller.add(
115
+ DownloadStatusUpdate(
116
+ modelId: model.id,
117
+ taskId: model.id,
118
+ status: DownloadStatus.complete,
119
+ progress: 100,
120
+ ),
121
+ );
122
+ }
123
+ _cleanup(model.id);
124
+ completer.complete(filePath);
125
+ }
126
+ },
127
+ cancelOnError: true,
128
+ );
129
+
130
+ _subscriptions[model.id] = subscription;
131
+
132
+ return completer.future;
133
+ }
134
+
135
+ /// Cancel an in-flight download for [modelId].
136
+ Future<void> cancelDownload(String modelId) async {
137
+ _downloadService.cancelDownload(modelId);
138
+ await _cleanup(modelId);
139
+ }
140
+
141
+ Future<void> _cleanup(String modelId) async {
142
+ await _subscriptions.remove(modelId)?.cancel();
143
+ final controller = _progressControllers.remove(modelId);
144
+ await controller?.close();
145
+ }
146
+
147
+ /// Pause is not supported — downloads are continuous streams.
148
+ void pauseDownload(String modelId) {
149
+ Log.warning('Pause not supported for foreground downloads');
150
+ }
151
+
152
+ /// Resume is not supported — start a new download instead.
153
+ void resumeDownload(String modelId) {
154
+ Log.warning('Resume not supported for foreground downloads');
155
+ }
156
+
157
+ /// Cancel and let the UI restart the download.
158
+ Future<void> retryDownload(String modelId) async {
159
+ await cancelDownload(modelId);
160
+ }
161
+}
lib/features/on_device/data/models/download_progress_info.dart
new
+33
@@ -0,0 +1,33 @@
1
+import 'download_status.dart';
2
+
3
+class DownloadProgressInfo {
4
+ final String modelId;
5
+ final DownloadStatus status;
6
+ final double progress;
7
+ final String? taskId;
8
+ final String? error;
9
+
10
+ const DownloadProgressInfo({
11
+ required this.modelId,
12
+ this.status = DownloadStatus.pending,
13
+ this.progress = 0.0,
14
+ this.taskId,
15
+ this.error,
16
+ });
17
+
18
+ DownloadProgressInfo copyWith({
19
+ String? modelId,
20
+ DownloadStatus? status,
21
+ double? progress,
22
+ String? taskId,
23
+ String? error,
24
+ }) {
25
+ return DownloadProgressInfo(
26
+ modelId: modelId ?? this.modelId,
27
+ status: status ?? this.status,
28
+ progress: progress ?? this.progress,
29
+ taskId: taskId ?? this.taskId,
30
+ error: error ?? this.error,
31
+ );
32
+ }
33
+}
lib/features/on_device/data/models/download_status.dart
new
+23
@@ -0,0 +1,23 @@
1
+enum DownloadStatus {
2
+ pending,
3
+ running,
4
+ paused,
5
+ canceled,
6
+ failed,
7
+ complete,
8
+ undefined,
9
+}
10
+
11
+class DownloadStatusUpdate {
12
+ final String modelId;
13
+ final String taskId;
14
+ final DownloadStatus status;
15
+ final int progress; // 0-100
16
+
17
+ DownloadStatusUpdate({
18
+ required this.modelId,
19
+ required this.taskId,
20
+ required this.status,
21
+ required this.progress,
22
+ });
23
+}
lib/features/on_device/data/models/on_device_model.dart
new
+150
@@ -0,0 +1,150 @@
1
+import '../../../../core/models/enums.dart';
2
+
3
+class OnDeviceModel {
4
+ final String id;
5
+ final String name;
6
+ final String huggingFaceUrl;
7
+ final int fileSizeBytes;
8
+ final String license;
9
+ final String description;
10
+ final bool isRecommended;
11
+ final int minRamMb;
12
+ final String parameterLabel;
13
+
14
+ const OnDeviceModel({
15
+ required this.id,
16
+ required this.name,
17
+ required this.huggingFaceUrl,
18
+ required this.fileSizeBytes,
19
+ required this.license,
20
+ required this.description,
21
+ this.isRecommended = false,
22
+ required this.minRamMb,
23
+ required this.parameterLabel,
24
+ });
25
+
26
+ String get fileSizeFormatted {
27
+ final gb = fileSizeBytes / (1024 * 1024 * 1024);
28
+ if (gb >= 1) {
29
+ return '${gb.toStringAsFixed(2)} GB';
30
+ }
31
+ final mb = fileSizeBytes / (1024 * 1024);
32
+ return '${mb.toStringAsFixed(0)} MB';
33
+ }
34
+
35
+ String get fileName => '$id.litertlm';
36
+
37
+ static const List<OnDeviceModel> curatedModels = [
38
+ OnDeviceModel(
39
+ id: 'qwen3-0.6b',
40
+ name: 'Qwen 3 0.6B',
41
+ huggingFaceUrl:
42
+ 'https://huggingface.co/litert-community/Qwen3-0.6B/resolve/main/Qwen3-0.6B.litertlm',
43
+ fileSizeBytes: 614236160,
44
+ license: 'Apache-2.0',
45
+ description:
46
+ 'Smallest general-purpose chat model. Fast responses, low memory usage.',
47
+ isRecommended: true,
48
+ minRamMb: 2048,
49
+ parameterLabel: '0.6B',
50
+ ),
51
+ OnDeviceModel(
52
+ id: 'qwen2.5-1.5b-instruct',
53
+ name: 'Qwen 2.5 1.5B Instruct',
54
+ huggingFaceUrl:
55
+ 'https://huggingface.co/litert-community/Qwen2.5-1.5B-Instruct/resolve/main/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm',
56
+ fileSizeBytes: 1597931520,
57
+ license: 'Apache-2.0',
58
+ description: 'Balanced quality and size. Good for general conversation.',
59
+ minRamMb: 3072,
60
+ parameterLabel: '1.5B',
61
+ ),
62
+ OnDeviceModel(
63
+ id: 'deepseek-r1-distill-qwen-1.5b',
64
+ name: 'DeepSeek R1 Distill Qwen 1.5B',
65
+ huggingFaceUrl:
66
+ 'https://huggingface.co/litert-community/DeepSeek-R1-Distill-Qwen-1.5B/resolve/main/DeepSeek-R1-Distill-Qwen-1.5B_multi-prefill-seq_q8_ekv4096.litertlm',
67
+ fileSizeBytes: 1833451520,
68
+ license: 'MIT',
69
+ description:
70
+ 'Reasoning and chain-of-thought model. Best for logical tasks.',
71
+ minRamMb: 3584,
72
+ parameterLabel: '1.5B',
73
+ ),
74
+ OnDeviceModel(
75
+ id: 'gemma4-e2b-instruct',
76
+ name: 'Gemma 4 E2B Instruct',
77
+ huggingFaceUrl:
78
+ 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm',
79
+ fileSizeBytes: 2583085056,
80
+ license: 'Apache-2.0',
81
+ description: 'Google flagship model. Highest quality, requires more RAM.',
82
+ minRamMb: 5120,
83
+ parameterLabel: '2B',
84
+ ),
85
+ ];
86
+}
87
+
88
+class DownloadedModel {
89
+ final String modelId;
90
+ final String filePath;
91
+ final DateTime downloadedAt;
92
+
93
+ const DownloadedModel({
94
+ required this.modelId,
95
+ required this.filePath,
96
+ required this.downloadedAt,
97
+ });
98
+
99
+ Map<String, dynamic> toMap() {
100
+ return {
101
+ 'modelId': modelId,
102
+ 'filePath': filePath,
103
+ 'downloadedAt': downloadedAt.toIso8601String(),
104
+ };
105
+ }
106
+
107
+ factory DownloadedModel.fromMap(Map<String, dynamic> map) {
108
+ return DownloadedModel(
109
+ modelId: map['modelId'] as String,
110
+ filePath: map['filePath'] as String,
111
+ downloadedAt: DateTime.parse(map['downloadedAt'] as String),
112
+ );
113
+ }
114
+}
115
+
116
+class OnDeviceModelStateInfo {
117
+ final String modelId;
118
+ final OnDeviceModelState state;
119
+ final double downloadProgress;
120
+ final String? error;
121
+ final LiteLmBackendType backend;
122
+ final EngineStatus engineStatus;
123
+
124
+ const OnDeviceModelStateInfo({
125
+ required this.modelId,
126
+ this.state = OnDeviceModelState.notDownloaded,
127
+ this.downloadProgress = 0.0,
128
+ this.error,
129
+ this.backend = LiteLmBackendType.cpu,
130
+ this.engineStatus = EngineStatus.notLoaded,
131
+ });
132
+
133
+ OnDeviceModelStateInfo copyWith({
134
+ String? modelId,
135
+ OnDeviceModelState? state,
136
+ double? downloadProgress,
137
+ String? error,
138
+ LiteLmBackendType? backend,
139
+ EngineStatus? engineStatus,
140
+ }) {
141
+ return OnDeviceModelStateInfo(
142
+ modelId: modelId ?? this.modelId,
143
+ state: state ?? this.state,
144
+ downloadProgress: downloadProgress ?? this.downloadProgress,
145
+ error: error ?? this.error,
146
+ backend: backend ?? this.backend,
147
+ engineStatus: engineStatus ?? this.engineStatus,
148
+ );
149
+ }
150
+}
lib/features/on_device/data/on_device_chat_service.dart
new
+167
@@ -0,0 +1,167 @@
1
+import 'dart:async';
2
+
3
+import 'package:flutter_litert_lm/flutter_litert_lm.dart';
4
+
5
+import '../../../core/logger/app_logger.dart';
6
+import '../../../core/models/enums.dart';
7
+import '../../chat/data/chat_service.dart';
8
+import '../../chat/data/models/chat_parameters.dart';
9
+import '../../chat/data/models/message.dart' hide ToolCallData;
10
+import '../../chat/data/models/mcp_integration.dart';
11
+import '../../servers/data/models/server.dart';
12
+import 'on_device_engine_service.dart';
13
+
14
+class OnDeviceChatService implements ChatService {
15
+ final OnDeviceEngineService _engineService;
16
+ StreamSubscription<LiteLmMessage>? _currentSubscription;
17
+ StreamController<ChatResponse>? _streamController;
18
+ bool _isCancelled = false;
19
+
20
+ OnDeviceChatService(this._engineService);
21
+
22
+ @override
23
+ Stream<ChatResponse> sendMessage({
24
+ required Server server,
25
+ required String modelId,
26
+ required List<Message> messages,
27
+ required ChatParameters params,
28
+ List<McpIntegration>? integrations,
29
+ String? previousResponseId,
30
+ }) {
31
+ _isCancelled = false;
32
+ _streamController = StreamController<ChatResponse>();
33
+
34
+ _startInference(messages, params);
35
+
36
+ return _streamController!.stream;
37
+ }
38
+
39
+ Future<void> _startInference(
40
+ List<Message> messages,
41
+ ChatParameters params,
42
+ ) async {
43
+ try {
44
+ if (_engineService.engine == null || _isCancelled) {
45
+ _streamController?.add(
46
+ const ChatResponse(
47
+ type: ChatResponseType.error,
48
+ content: 'Engine not loaded',
49
+ ),
50
+ );
51
+ _streamController?.add(const ChatResponse(type: ChatResponseType.done));
52
+ await _streamController?.close();
53
+ return;
54
+ }
55
+
56
+ final systemInstruction = params.systemPrompt?.isNotEmpty == true
57
+ ? params.systemPrompt
58
+ : null;
59
+
60
+ final samplerConfig = LiteLmSamplerConfig(
61
+ temperature: params.temperature,
62
+ topK: 40,
63
+ topP: params.topP,
64
+ );
65
+
66
+ final conversation = await _engineService.createConversation(
67
+ systemInstruction: systemInstruction,
68
+ samplerConfig: samplerConfig,
69
+ );
70
+
71
+ final liteMessages = _convertMessages(messages);
72
+
73
+ if (_isCancelled) {
74
+ await conversation.dispose();
75
+ _streamController?.add(const ChatResponse(type: ChatResponseType.done));
76
+ await _streamController?.close();
77
+ return;
78
+ }
79
+
80
+ final lastUserMessage = liteMessages
81
+ .where((m) => m.role == 'user')
82
+ .lastOrNull;
83
+ if (lastUserMessage == null) {
84
+ _streamController?.add(
85
+ const ChatResponse(
86
+ type: ChatResponseType.error,
87
+ content: 'No user message found',
88
+ ),
89
+ );
90
+ _streamController?.add(const ChatResponse(type: ChatResponseType.done));
91
+ await _streamController?.close();
92
+ return;
93
+ }
94
+
95
+ final stream = conversation.sendMessageStream(lastUserMessage.text ?? '');
96
+
97
+ final buffer = StringBuffer();
98
+ await for (final delta in stream) {
99
+ if (_isCancelled) {
100
+ await conversation.dispose();
101
+ _streamController?.add(
102
+ const ChatResponse(type: ChatResponseType.done),
103
+ );
104
+ await _streamController?.close();
105
+ return;
106
+ }
107
+
108
+ if (delta.text.isNotEmpty) {
109
+ buffer.write(delta.text);
110
+ _streamController?.add(
111
+ ChatResponse(type: ChatResponseType.message, content: delta.text),
112
+ );
113
+ }
114
+
115
+ if (delta.toolCalls.isNotEmpty) {
116
+ for (final tc in delta.toolCalls) {
117
+ _streamController?.add(
118
+ ChatResponse(
119
+ type: ChatResponseType.toolCall,
120
+ toolCall: ToolCallData(tool: tc.name, arguments: tc.arguments),
121
+ ),
122
+ );
123
+ }
124
+ }
125
+ }
126
+
127
+ _streamController?.add(const ChatResponse(type: ChatResponseType.done));
128
+ await conversation.dispose();
129
+ await _streamController?.close();
130
+ } catch (e) {
131
+ Log.error('OnDevice inference error: $e');
132
+ if (!(_streamController?.isClosed ?? true)) {
133
+ _streamController?.add(
134
+ ChatResponse(
135
+ type: ChatResponseType.error,
136
+ content: 'Inference error: ${e.toString()}',
137
+ ),
138
+ );
139
+ _streamController?.add(const ChatResponse(type: ChatResponseType.done));
140
+ await _streamController?.close();
141
+ }
142
+ }
143
+ }
144
+
145
+ List<({String role, String? text})> _convertMessages(List<Message> messages) {
146
+ return messages
147
+ .where(
148
+ (m) =>
149
+ m.role == MessageRole.user ||
150
+ m.role == MessageRole.assistant ||
151
+ m.role == MessageRole.system,
152
+ )
153
+ .map((m) => (role: m.role.name, text: m.content))
154
+ .toList();
155
+ }
156
+
157
+ @override
158
+ void cancelStream() {
159
+ _isCancelled = true;
160
+ _currentSubscription?.cancel();
161
+ _currentSubscription = null;
162
+ if (!(_streamController?.isClosed ?? true)) {
163
+ _streamController?.add(const ChatResponse(type: ChatResponseType.done));
164
+ _streamController?.close();
165
+ }
166
+ }
167
+}
lib/features/on_device/data/on_device_engine_service.dart
new
+132
@@ -0,0 +1,132 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import 'package:flutter_litert_lm/flutter_litert_lm.dart';
5
+import 'package:path_provider/path_provider.dart';
6
+
7
+import '../../../core/logger/app_logger.dart';
8
+import '../../../core/models/enums.dart';
9
+
10
+class OnDeviceEngineService {
11
+ LiteLmEngine? _engine;
12
+ String? _currentModelPath;
13
+ LiteLmBackendType? _currentBackend;
14
+ bool _isDisposed = false;
15
+
16
+ LiteLmEngine? get engine => _engine;
17
+ String? get currentModelPath => _currentModelPath;
18
+ bool get isLoaded => _engine != null && !_isDisposed;
19
+ bool get isDisposed => _isDisposed;
20
+
21
+ Future<LiteLmEngine> createEngine(
22
+ String modelPath,
23
+ LiteLmBackendType backendType,
24
+ ) async {
25
+ if (_isDisposed) {
26
+ throw StateError('OnDeviceEngineService has been disposed');
27
+ }
28
+
29
+ if (_engine != null &&
30
+ _currentModelPath == modelPath &&
31
+ _currentBackend == backendType) {
32
+ return _engine!;
33
+ }
34
+
35
+ if (_engine != null) {
36
+ await disposeEngine();
37
+ }
38
+
39
+ final backend = _mapBackendType(backendType);
40
+
41
+ Log.info(
42
+ 'Creating LiteLmEngine with modelPath=$modelPath, backend=$backendType',
43
+ );
44
+
45
+ final config = LiteLmEngineConfig(modelPath: modelPath, backend: backend);
46
+
47
+ _engine = await LiteLmEngine.create(config);
48
+ _currentModelPath = modelPath;
49
+ _currentBackend = backendType;
50
+
51
+ Log.info('LiteLmEngine created successfully');
52
+ return _engine!;
53
+ }
54
+
55
+ Future<LiteLmConversation> createConversation({
56
+ String? systemInstruction,
57
+ LiteLmSamplerConfig? samplerConfig,
58
+ List<LiteLmTool>? tools,
59
+ }) async {
60
+ if (_engine == null) {
61
+ throw StateError('Engine not loaded. Call createEngine first.');
62
+ }
63
+
64
+ final config = LiteLmConversationConfig(
65
+ systemInstruction: systemInstruction,
66
+ samplerConfig: samplerConfig,
67
+ tools: tools,
68
+ );
69
+
70
+ return await _engine!.createConversation(config);
71
+ }
72
+
73
+ Future<void> disposeEngine() async {
74
+ if (_engine != null) {
75
+ try {
76
+ await _engine!.dispose();
77
+ } catch (e) {
78
+ Log.error('Error disposing engine: $e');
79
+ }
80
+ _engine = null;
81
+ _currentModelPath = null;
82
+ _currentBackend = null;
83
+ }
84
+ }
85
+
86
+ void dispose() {
87
+ _isDisposed = true;
88
+ disposeEngine();
89
+ }
90
+
91
+ LiteLmBackend _mapBackendType(LiteLmBackendType type) {
92
+ switch (type) {
93
+ case LiteLmBackendType.cpu:
94
+ return LiteLmBackend.cpu;
95
+ case LiteLmBackendType.gpu:
96
+ return LiteLmBackend.gpu;
97
+ case LiteLmBackendType.npu:
98
+ return LiteLmBackend.npu;
99
+ }
100
+ }
101
+
102
+ static Future<String> getModelDirectory() async {
103
+ final dir = await getApplicationSupportDirectory();
104
+ final modelsDir = Directory('${dir.path}/on_device_models');
105
+ if (!await modelsDir.exists()) {
106
+ await modelsDir.create(recursive: true);
107
+ }
108
+ return modelsDir.path;
109
+ }
110
+
111
+ static Future<String?> getModelPath(String modelId) async {
112
+ final dir = await getModelDirectory();
113
+ final file = File('$dir/$modelId.litertlm');
114
+ if (await file.exists()) {
115
+ return file.path;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ static Future<bool> isModelDownloaded(String modelId) async {
121
+ final path = await getModelPath(modelId);
122
+ return path != null;
123
+ }
124
+
125
+ static Future<void> deleteModel(String modelId) async {
126
+ final dir = await getModelDirectory();
127
+ final file = File('$dir/$modelId.litertlm');
128
+ if (await file.exists()) {
129
+ await file.delete();
130
+ }
131
+ }
132
+}
lib/features/on_device/data/on_device_model_download_service.dart
new
+250
@@ -0,0 +1,250 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import '../../../core/logger/app_logger.dart';
5
+import 'models/on_device_model.dart';
6
+import 'on_device_engine_service.dart';
7
+
8
+/// Snapshot of an in-flight download.
9
+class DownloadProgress {
10
+ const DownloadProgress({required this.received, required this.total});
11
+
12
+ final int received;
13
+ final int total;
14
+
15
+ double get fraction => total <= 0 ? 0 : received / total;
16
+}
17
+
18
+/// Downloads `.litertlm` model files from HuggingFace using `dart:io HttpClient`.
19
+///
20
+/// Follows redirects manually so the `Authorization` header is only sent to
21
+/// the original HuggingFace host (not the CDN). Downloads stream to a `.part`
22
+/// file and are renamed atomically on success.
23
+class OnDeviceModelDownloadService {
24
+ final Map<String, HttpClient> _activeClients = {};
25
+
26
+ /// Downloads [model] from HuggingFace, emitting progress updates.
27
+ ///
28
+ /// [token] is an optional HuggingFace access token for gated models.
29
+ Stream<DownloadProgress> downloadModel(
30
+ OnDeviceModel model, {
31
+ String? token,
32
+ }) async* {
33
+ final modelsDir = await OnDeviceEngineService.getModelDirectory();
34
+ final finalPath = '$modelsDir/${model.fileName}';
35
+ final partialPath = '$finalPath.part';
36
+
37
+ // Already downloaded
38
+ if (await File(finalPath).exists()) {
39
+ Log.info('Model ${model.id} already downloaded at $finalPath');
40
+ return;
41
+ }
42
+
43
+ // Clean up any stale partial file from a previous attempt
44
+ final partialFile = File(partialPath);
45
+ if (await partialFile.exists()) {
46
+ await partialFile.delete();
47
+ }
48
+
49
+ final client = HttpClient();
50
+ _activeClients[model.id] = client;
51
+ IOSink? sink;
52
+
53
+ try {
54
+ final response = await _resolveWithRedirects(
55
+ client: client,
56
+ url: Uri.parse(model.huggingFaceUrl),
57
+ token: token,
58
+ );
59
+
60
+ final total = response.contentLength > 0
61
+ ? response.contentLength
62
+ : model.fileSizeBytes;
63
+ var received = 0;
64
+
65
+ sink = partialFile.openWrite();
66
+
67
+ await for (final chunk in response) {
68
+ sink.add(chunk);
69
+ received += chunk.length;
70
+ yield DownloadProgress(received: received, total: total);
71
+ }
72
+
73
+ await sink.flush();
74
+ await sink.close();
75
+ sink = null;
76
+
77
+ // Atomically promote .part file to final name
78
+ await partialFile.rename(finalPath);
79
+
80
+ Log.info('Model ${model.id} downloaded successfully to $finalPath');
81
+ } catch (e) {
82
+ // Clean up partial file on failure
83
+ try {
84
+ if (await partialFile.exists()) {
85
+ await partialFile.delete();
86
+ }
87
+ } catch (_) {}
88
+ Log.error('Failed to download model ${model.id}: $e');
89
+ rethrow;
90
+ } finally {
91
+ _activeClients.remove(model.id);
92
+ try {
93
+ await sink?.close();
94
+ } catch (_) {}
95
+ client.close(force: true);
96
+ }
97
+ }
98
+
99
+ /// Cancel an in-flight download for [modelId].
100
+ void cancelDownload(String modelId) {
101
+ final client = _activeClients[modelId];
102
+ if (client != null) {
103
+ client.close(force: true);
104
+ _activeClients.remove(modelId);
105
+ }
106
+ }
107
+
108
+ /// Manually follow redirects, passing the HF `Authorization` header
109
+ /// only on the original host. The CDN URLs returned by HuggingFace are
110
+ /// pre-signed and may reject extra auth headers.
111
+ Future<HttpClientResponse> _resolveWithRedirects({
112
+ required HttpClient client,
113
+ required Uri url,
114
+ required String? token,
115
+ }) async {
116
+ var current = url;
117
+ final originHost = url.host;
118
+
119
+ for (var hop = 0; hop < 6; hop++) {
120
+ final request = await client.getUrl(current);
121
+ request.followRedirects = false;
122
+
123
+ // Only send bearer token to the original HuggingFace host
124
+ final cleanedToken = token?.trim();
125
+ if (cleanedToken != null &&
126
+ cleanedToken.isNotEmpty &&
127
+ current.host == originHost) {
128
+ request.headers.set('Authorization', 'Bearer $cleanedToken');
129
+ }
130
+
131
+ final response = await request.close();
132
+
133
+ if (response.statusCode == 200) {
134
+ return response;
135
+ }
136
+
137
+ if (response.isRedirect) {
138
+ final location = response.headers.value('location');
139
+ await response.drain<void>();
140
+ if (location == null) {
141
+ throw HttpException(
142
+ 'Redirect with no Location header (status ${response.statusCode})',
143
+ );
144
+ }
145
+ current = current.resolve(location);
146
+ continue;
147
+ }
148
+
149
+ // Read a small preview of the error body
150
+ final body = await _readBodyPreview(response);
151
+ if (response.statusCode == 401 || response.statusCode == 403) {
152
+ throw HttpException(
153
+ 'Access denied (HTTP ${response.statusCode}). '
154
+ '${body.isNotEmpty ? "Server said: $body" : ""}',
155
+ );
156
+ }
157
+ throw HttpException(
158
+ 'Download failed: HTTP ${response.statusCode}'
159
+ '${body.isNotEmpty ? " — $body" : ""}',
160
+ );
161
+ }
162
+
163
+ throw const HttpException('Too many redirects');
164
+ }
165
+
166
+ /// Read at most ~512 bytes of the response body for error messages.
167
+ Future<String> _readBodyPreview(HttpClientResponse response) async {
168
+ try {
169
+ final buffer = StringBuffer();
170
+ var read = 0;
171
+ await for (final chunk in response) {
172
+ buffer.write(String.fromCharCodes(chunk));
173
+ read += chunk.length;
174
+ if (read >= 512) break;
175
+ }
176
+ return buffer.toString().trim().replaceAll(RegExp(r'\s+'), ' ');
177
+ } catch (_) {
178
+ return '';
179
+ }
180
+ }
181
+
182
+ /// Delete a downloaded model from disk.
183
+ Future<void> deleteModel(String modelId) async {
184
+ final modelsDir = await OnDeviceEngineService.getModelDirectory();
185
+ final file = File('$modelsDir/$modelId.litertlm');
186
+ if (await file.exists()) {
187
+ await file.delete();
188
+ Log.info('Model $modelId deleted');
189
+ }
190
+ }
191
+
192
+ /// List all downloaded models.
193
+ Future<List<DownloadedModel>> getDownloadedModels() async {
194
+ final modelsDir = await OnDeviceEngineService.getModelDirectory();
195
+ final dir = Directory(modelsDir);
196
+ if (!await dir.exists()) {
197
+ return [];
198
+ }
199
+
200
+ final List<DownloadedModel> result = [];
201
+ final entities = dir.listSync();
202
+ for (final entity in entities) {
203
+ if (entity is File && entity.path.endsWith('.litertlm')) {
204
+ final fileName = entity.path.split('/').last;
205
+ final modelId = fileName.replaceAll('.litertlm', '');
206
+ final stat = await entity.stat();
207
+ result.add(
208
+ DownloadedModel(
209
+ modelId: modelId,
210
+ filePath: entity.path,
211
+ downloadedAt: stat.modified,
212
+ ),
213
+ );
214
+ }
215
+ }
216
+ return result;
217
+ }
218
+
219
+ /// Check if a model has been downloaded.
220
+ Future<bool> isModelDownloaded(String modelId) async {
221
+ final modelsDir = await OnDeviceEngineService.getModelDirectory();
222
+ final file = File('$modelsDir/$modelId.litertlm');
223
+ return file.exists();
224
+ }
225
+
226
+ /// Get total size of all downloaded models.
227
+ Future<int> getModelsTotalSizeBytes() async {
228
+ final models = await getDownloadedModels();
229
+ int total = 0;
230
+ for (final model in models) {
231
+ final file = File(model.filePath);
232
+ if (await file.exists()) {
233
+ total += await file.length();
234
+ }
235
+ }
236
+ return total;
237
+ }
238
+}
239
+
240
+class DownloadedModel {
241
+ final String modelId;
242
+ final String filePath;
243
+ final DateTime downloadedAt;
244
+
245
+ const DownloadedModel({
246
+ required this.modelId,
247
+ required this.filePath,
248
+ required this.downloadedAt,
249
+ });
250
+}
lib/features/on_device/providers/foreground_download_providers.dart
new
+132
@@ -0,0 +1,132 @@
1
+import 'dart:async';
2
+
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+
5
+import '../data/models/download_progress_info.dart';
6
+import '../data/models/download_status.dart';
7
+import 'on_device_providers.dart';
8
+
9
+final foregroundDownloadNotifierProvider =
10
+ NotifierProvider<
11
+ ForegroundDownloadNotifier,
12
+ Map<String, DownloadProgressInfo>
13
+ >(() {
14
+ return ForegroundDownloadNotifier();
15
+ });
16
+
17
+class ForegroundDownloadNotifier
18
+ extends Notifier<Map<String, DownloadProgressInfo>> {
19
+ final Map<String, StreamSubscription<DownloadStatusUpdate>> _subscriptions =
20
+ {};
21
+
22
+ @override
23
+ Map<String, DownloadProgressInfo> build() {
24
+ ref.onDispose(() {
25
+ for (final sub in _subscriptions.values) {
26
+ sub.cancel();
27
+ }
28
+ _subscriptions.clear();
29
+ });
30
+ return {};
31
+ }
32
+
33
+ Future<void> startDownload(String modelId) async {
34
+ final models = ref.read(onDeviceModelsProvider);
35
+ final model = models.firstWhere(
36
+ (m) => m.id == modelId,
37
+ orElse: () => throw Exception('Model not found: $modelId'),
38
+ );
39
+
40
+ state = {
41
+ ...state,
42
+ modelId: DownloadProgressInfo(
43
+ modelId: modelId,
44
+ status: DownloadStatus.running,
45
+ progress: 0.0,
46
+ ),
47
+ };
48
+
49
+ final downloadService = ref.read(foregroundDownloadServiceProvider);
50
+
51
+ // Listen to status updates for this model
52
+ _subscriptions[modelId]?.cancel();
53
+ _subscriptions[modelId] = downloadService.getStatusStream(modelId).listen((
54
+ update,
55
+ ) {
56
+ final info = DownloadProgressInfo(
57
+ modelId: update.modelId,
58
+ status: update.status,
59
+ progress: update.progress / 100.0,
60
+ taskId: update.taskId,
61
+ );
62
+ state = {...state, update.modelId: info};
63
+
64
+ if (update.status == DownloadStatus.complete) {
65
+ ref.invalidate(downloadedModelsProvider);
66
+ state = {...state}..remove(update.modelId);
67
+ _removeCompletedDownload(update.modelId);
68
+ } else if (update.status == DownloadStatus.failed ||
69
+ update.status == DownloadStatus.canceled) {
70
+ state = {
71
+ ...state,
72
+ update.modelId: info.copyWith(
73
+ status: update.status,
74
+ error: update.status == DownloadStatus.failed
75
+ ? 'Download failed'
76
+ : 'Download canceled',
77
+ ),
78
+ };
79
+ _removeCompletedDownload(update.modelId);
80
+ }
81
+ });
82
+
83
+ try {
84
+ await downloadService.downloadModel(model);
85
+ } catch (e) {
86
+ // Error already handled via stream
87
+ }
88
+ }
89
+
90
+ void _removeCompletedDownload(String modelId) {
91
+ final current = Map<String, DownloadProgressInfo>.from(state);
92
+ current.remove(modelId);
93
+ state = current;
94
+ _subscriptions[modelId]?.cancel();
95
+ _subscriptions.remove(modelId);
96
+ }
97
+
98
+ Future<void> cancelDownload(String modelId) async {
99
+ final downloadService = ref.read(foregroundDownloadServiceProvider);
100
+ await downloadService.cancelDownload(modelId);
101
+ state = {...state}..remove(modelId);
102
+ _subscriptions[modelId]?.cancel();
103
+ _subscriptions.remove(modelId);
104
+ }
105
+
106
+ // Pause and resume are not supported
107
+ Future<void> pauseDownload(String modelId) async {
108
+ // No-op
109
+ }
110
+
111
+ Future<void> resumeDownload(String modelId) async {
112
+ // No-op
113
+ }
114
+
115
+ Future<void> retryDownload(String modelId) async {
116
+ final downloadService = ref.read(foregroundDownloadServiceProvider);
117
+ await downloadService.retryDownload(modelId);
118
+ // Restart download
119
+ await startDownload(modelId);
120
+ }
121
+
122
+ DownloadProgressInfo? getProgressForModel(String modelId) {
123
+ return state[modelId];
124
+ }
125
+
126
+ bool isDownloading(String modelId) {
127
+ final info = state[modelId];
128
+ return info != null &&
129
+ (info.status == DownloadStatus.running ||
130
+ info.status == DownloadStatus.pending);
131
+ }
132
+}
lib/features/on_device/providers/on_device_providers.dart
new
+200
@@ -0,0 +1,200 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+
6
+import '../../../core/logger/app_logger.dart';
7
+import '../../../core/models/enums.dart';
8
+import '../data/models/on_device_model.dart';
9
+import '../data/on_device_engine_service.dart';
10
+import '../data/on_device_model_download_service.dart';
11
+import '../data/foreground_download_service.dart';
12
+
13
+final onDeviceEngineProvider =
14
+ NotifierProvider<OnDeviceEngineNotifier, OnDeviceEngineState>(() {
15
+ return OnDeviceEngineNotifier();
16
+ });
17
+
18
+final onDeviceModelsProvider = Provider<List<OnDeviceModel>>((ref) {
19
+ return OnDeviceModel.curatedModels;
20
+});
21
+
22
+final onDeviceDownloadServiceProvider = Provider<OnDeviceModelDownloadService>((
23
+ ref,
24
+) {
25
+ return OnDeviceModelDownloadService();
26
+});
27
+
28
+final foregroundDownloadServiceProvider = Provider<ForegroundDownloadService>((
29
+ ref,
30
+) {
31
+ return ForegroundDownloadService(ref.read(onDeviceDownloadServiceProvider));
32
+});
33
+
34
+final downloadedModelsProvider = FutureProvider<Set<String>>((ref) async {
35
+ final downloadService = ref.read(onDeviceDownloadServiceProvider);
36
+ final models = await downloadService.getDownloadedModels();
37
+ return models.map((m) => m.modelId).toSet();
38
+});
39
+
40
+final onDeviceModelStateProvider =
41
+ NotifierProvider<
42
+ OnDeviceModelStateNotifier,
43
+ Map<String, OnDeviceModelStateInfo>
44
+ >(() {
45
+ return OnDeviceModelStateNotifier();
46
+ });
47
+
48
+class OnDeviceEngineState {
49
+ final EngineStatus status;
50
+ final String? loadedModelId;
51
+ final String? loadedModelPath;
52
+ final LiteLmBackendType? backend;
53
+ final String? error;
54
+
55
+ const OnDeviceEngineState({
56
+ this.status = EngineStatus.notLoaded,
57
+ this.loadedModelId,
58
+ this.loadedModelPath,
59
+ this.backend,
60
+ this.error,
61
+ });
62
+
63
+ OnDeviceEngineState copyWith({
64
+ EngineStatus? status,
65
+ String? loadedModelId,
66
+ String? loadedModelPath,
67
+ LiteLmBackendType? backend,
68
+ String? error,
69
+ }) {
70
+ return OnDeviceEngineState(
71
+ status: status ?? this.status,
72
+ loadedModelId: loadedModelId ?? this.loadedModelId,
73
+ loadedModelPath: loadedModelPath ?? this.loadedModelPath,
74
+ backend: backend ?? this.backend,
75
+ error: error ?? this.error,
76
+ );
77
+ }
78
+}
79
+
80
+class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
81
+ OnDeviceEngineService? _engineService;
82
+
83
+ @override
84
+ OnDeviceEngineState build() {
85
+ return const OnDeviceEngineState();
86
+ }
87
+
88
+ OnDeviceEngineService get engineService {
89
+ _engineService ??= OnDeviceEngineService();
90
+ return _engineService!;
91
+ }
92
+
93
+ Future<void> loadModel(String modelId, LiteLmBackendType backend) async {
94
+ state = state.copyWith(status: EngineStatus.loading, error: null);
95
+
96
+ try {
97
+ final modelPath = await OnDeviceEngineService.getModelPath(modelId);
98
+ if (modelPath == null) {
99
+ state = state.copyWith(
100
+ status: EngineStatus.error,
101
+ error: 'Model not found. Please download it first.',
102
+ );
103
+ return;
104
+ }
105
+
106
+ await engineService.createEngine(modelPath, backend);
107
+
108
+ state = state.copyWith(
109
+ status: EngineStatus.loaded,
110
+ loadedModelId: modelId,
111
+ loadedModelPath: modelPath,
112
+ backend: backend,
113
+ );
114
+
115
+ Log.info('Model $modelId loaded successfully with ${backend.name}');
116
+ } catch (e) {
117
+ Log.error('Failed to load model $modelId: $e');
118
+ state = state.copyWith(
119
+ status: EngineStatus.error,
120
+ error: 'Failed to load model: ${e.toString()}',
121
+ );
122
+ }
123
+ }
124
+
125
+ Future<void> unloadModel() async {
126
+ try {
127
+ await engineService.disposeEngine();
128
+ } catch (_) {}
129
+ state = const OnDeviceEngineState();
130
+ }
131
+
132
+ Future<void> dispose() async {
133
+ engineService.dispose();
134
+ state = const OnDeviceEngineState();
135
+ }
136
+}
137
+
138
+class OnDeviceModelStateNotifier
139
+ extends Notifier<Map<String, OnDeviceModelStateInfo>> {
140
+ @override
141
+ Map<String, OnDeviceModelStateInfo> build() {
142
+ return {};
143
+ }
144
+
145
+ void updateModelState(
146
+ String modelId, {
147
+ OnDeviceModelState? modelState,
148
+ double? downloadProgress,
149
+ String? error,
150
+ LiteLmBackendType? backend,
151
+ EngineStatus? engineStatus,
152
+ }) {
153
+ final current =
154
+ this.state[modelId] ?? OnDeviceModelStateInfo(modelId: modelId);
155
+ this.state = {
156
+ ...this.state,
157
+ modelId: current.copyWith(
158
+ state: modelState ?? current.state,
159
+ downloadProgress: downloadProgress ?? current.downloadProgress,
160
+ error: error ?? current.error,
161
+ backend: backend ?? current.backend,
162
+ engineStatus: engineStatus ?? current.engineStatus,
163
+ ),
164
+ };
165
+ }
166
+
167
+ void setDownloading(String modelId, double progress) {
168
+ updateModelState(
169
+ modelId,
170
+ modelState: OnDeviceModelState.downloading,
171
+ downloadProgress: progress,
172
+ );
173
+ }
174
+
175
+ void setDownloaded(String modelId) {
176
+ updateModelState(
177
+ modelId,
178
+ modelState: OnDeviceModelState.downloaded,
179
+ downloadProgress: 1.0,
180
+ );
181
+ }
182
+
183
+ void setDownloadError(String modelId, String error) {
184
+ updateModelState(
185
+ modelId,
186
+ modelState: OnDeviceModelState.error,
187
+ error: error,
188
+ );
189
+ }
190
+
191
+ void removeModel(String modelId) {
192
+ final newState = Map<String, OnDeviceModelStateInfo>.from(state);
193
+ newState.remove(modelId);
194
+ state = newState;
195
+ }
196
+}
197
+
198
+final isOnDevicePlatformSupportedProvider = Provider<bool>((ref) {
199
+ return Platform.isAndroid;
200
+});
lib/features/on_device/views/backend_selector.dart
new
+118
@@ -0,0 +1,118 @@
1
+import 'dart:io';
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/models/enums.dart';
8
+import '../../../core/providers/app_providers.dart';
9
+
10
+class BackendSelector extends ConsumerWidget {
11
+ const BackendSelector({super.key});
12
+
13
+ @override
14
+ Widget build(BuildContext context, WidgetRef ref) {
15
+ final settings = ref.watch(settingsProvider);
16
+ final currentBackend = settings.preferredBackend;
17
+ final isAndroid = Platform.isAndroid;
18
+
19
+ return Column(
20
+ crossAxisAlignment: CrossAxisAlignment.start,
21
+ children: [
22
+ Text(
23
+ 'Inference Backend',
24
+ style: Theme.of(
25
+ context,
26
+ ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
27
+ ),
28
+ const SizedBox(height: 8),
29
+ if (!isAndroid)
30
+ Container(
31
+ padding: const EdgeInsets.all(12),
32
+ margin: const EdgeInsets.only(bottom: 12),
33
+ decoration: BoxDecoration(
34
+ color: Colors.orange.withValues(alpha: 0.1),
35
+ borderRadius: BorderRadius.circular(8),
36
+ border: Border.all(color: Colors.orange),
37
+ ),
38
+ child: const Row(
39
+ children: [
40
+ Icon(Icons.info_outline, color: Colors.orange, size: 20),
41
+ SizedBox(width: 8),
42
+ Expanded(
43
+ child: Text(
44
+ 'Only CPU backend is available on iOS.',
45
+ style: TextStyle(color: Colors.orange),
46
+ ),
47
+ ),
48
+ ],
49
+ ),
50
+ ),
51
+ ...LiteLmBackendType.values.map((backend) {
52
+ final isAvailable = isAndroid || backend == LiteLmBackendType.cpu;
53
+ final isSelected = currentBackend == backend;
54
+ return Padding(
55
+ padding: const EdgeInsets.only(bottom: 8),
56
+ child: Opacity(
57
+ opacity: isAvailable ? 1.0 : 0.4,
58
+ child: ShadButton.outline(
59
+ width: double.infinity,
60
+ onPressed: isAvailable
61
+ ? () => ref
62
+ .read(settingsProvider.notifier)
63
+ .setPreferredBackend(backend)
64
+ : null,
65
+ child: Row(
66
+ children: [
67
+ Icon(
68
+ isSelected
69
+ ? Icons.radio_button_checked
70
+ : Icons.radio_button_off,
71
+ size: 20,
72
+ color: isSelected
73
+ ? Theme.of(context).colorScheme.primary
74
+ : Theme.of(
75
+ context,
76
+ ).colorScheme.onSurface.withValues(alpha: 0.5),
77
+ ),
78
+ const SizedBox(width: 12),
79
+ Expanded(
80
+ child: Column(
81
+ crossAxisAlignment: CrossAxisAlignment.start,
82
+ children: [
83
+ Text(
84
+ backend.name.toUpperCase(),
85
+ style: const TextStyle(fontWeight: FontWeight.w600),
86
+ ),
87
+ Text(
88
+ _backendDescription(backend),
89
+ style: Theme.of(context).textTheme.bodySmall
90
+ ?.copyWith(
91
+ color: Theme.of(context).colorScheme.onSurface
92
+ .withValues(alpha: 0.6),
93
+ ),
94
+ ),
95
+ ],
96
+ ),
97
+ ),
98
+ ],
99
+ ),
100
+ ),
101
+ ),
102
+ );
103
+ }),
104
+ ],
105
+ );
106
+ }
107
+
108
+ String _backendDescription(LiteLmBackendType backend) {
109
+ switch (backend) {
110
+ case LiteLmBackendType.cpu:
111
+ return 'Works on all devices. Most compatible.';
112
+ case LiteLmBackendType.gpu:
113
+ return 'OpenCL acceleration. Faster on supported devices.';
114
+ case LiteLmBackendType.npu:
115
+ return 'Vendor NPU (Qualcomm/MediaTek). Fastest inference.';
116
+ }
117
+ }
118
+}
lib/features/on_device/views/model_manager_screen.dart
new
+405
@@ -0,0 +1,405 @@
1
+import 'dart:io';
2
+
3
+import 'package:flutter/material.dart';
4
+import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+import 'package:localmind/features/sidebar/sidebar_widget.dart';
6
+import 'package:shadcn_ui/shadcn_ui.dart';
7
+
8
+import '../../../core/models/enums.dart';
9
+import '../../../core/providers/app_providers.dart';
10
+import '../../on_device/data/models/on_device_model.dart';
11
+import '../../on_device/data/models/download_progress_info.dart';
12
+import '../../on_device/data/models/download_status.dart';
13
+import '../../on_device/providers/on_device_providers.dart';
14
+import '../../on_device/providers/foreground_download_providers.dart';
15
+
16
+class OnDeviceModelManagerScreen extends ConsumerWidget {
17
+ const OnDeviceModelManagerScreen({super.key});
18
+
19
+ @override
20
+ Widget build(BuildContext context, WidgetRef ref) {
21
+ final theme = Theme.of(context);
22
+ final models = ref.watch(onDeviceModelsProvider);
23
+ final downloadedAsync = ref.watch(downloadedModelsProvider);
24
+ final engineState = ref.watch(onDeviceEngineProvider);
25
+ final downloadProgress = ref.watch(foregroundDownloadNotifierProvider);
26
+ final isAndroid = Platform.isAndroid;
27
+
28
+ return Scaffold(
29
+ drawer: const SidebarWidget(),
30
+ appBar: AppBar(title: const Text('On-Device Models')),
31
+ body: ListView(
32
+ padding: const EdgeInsets.all(16),
33
+ children: [
34
+ if (!isAndroid)
35
+ Container(
36
+ padding: const EdgeInsets.all(12),
37
+ margin: const EdgeInsets.only(bottom: 16),
38
+ decoration: BoxDecoration(
39
+ color: Colors.orange.withValues(alpha: 0.1),
40
+ borderRadius: BorderRadius.circular(8),
41
+ border: Border.all(color: Colors.orange),
42
+ ),
43
+ child: const Row(
44
+ children: [
45
+ Icon(Icons.info_outline, color: Colors.orange, size: 20),
46
+ SizedBox(width: 8),
47
+ Expanded(
48
+ child: Text(
49
+ 'On-device inference is available on Android only.',
50
+ style: TextStyle(color: Colors.orange),
51
+ ),
52
+ ),
53
+ ],
54
+ ),
55
+ ),
56
+ if (engineState.status == EngineStatus.loaded)
57
+ Container(
58
+ padding: const EdgeInsets.all(12),
59
+ margin: const EdgeInsets.only(bottom: 16),
60
+ decoration: BoxDecoration(
61
+ color: Colors.green.withValues(alpha: 0.1),
62
+ borderRadius: BorderRadius.circular(8),
63
+ border: Border.all(color: Colors.green),
64
+ ),
65
+ child: Row(
66
+ children: [
67
+ const Icon(Icons.check_circle, color: Colors.green, size: 20),
68
+ const SizedBox(width: 8),
69
+ Expanded(
70
+ child: Text(
71
+ 'Model loaded: ${engineState.loadedModelId ?? "Unknown"} (${engineState.backend?.name ?? "CPU"})',
72
+ style: const TextStyle(color: Colors.green),
73
+ ),
74
+ ),
75
+ ],
76
+ ),
77
+ )
78
+ else if (engineState.status == EngineStatus.loading)
79
+ const Padding(
80
+ padding: EdgeInsets.only(bottom: 16),
81
+ child: LinearProgressIndicator(),
82
+ )
83
+ else if (engineState.status == EngineStatus.error)
84
+ Container(
85
+ padding: const EdgeInsets.all(12),
86
+ margin: const EdgeInsets.only(bottom: 16),
87
+ decoration: BoxDecoration(
88
+ color: Colors.red.withValues(alpha: 0.1),
89
+ borderRadius: BorderRadius.circular(8),
90
+ border: Border.all(color: Colors.red),
91
+ ),
92
+ child: Text(
93
+ engineState.error ?? 'Engine error',
94
+ style: const TextStyle(color: Colors.red),
95
+ ),
96
+ ),
97
+ Text(
98
+ 'Available Models',
99
+ style: theme.textTheme.titleMedium?.copyWith(
100
+ fontWeight: FontWeight.bold,
101
+ ),
102
+ ),
103
+ const SizedBox(height: 12),
104
+ ...models.map(
105
+ (model) => _ModelCard(
106
+ model: model,
107
+ theme: theme,
108
+ downloadedAsync: downloadedAsync,
109
+ downloadProgress: downloadProgress[model.id],
110
+ engineState: engineState,
111
+ ),
112
+ ),
113
+ const SizedBox(height: 24),
114
+ ],
115
+ ),
116
+ );
117
+ }
118
+}
119
+
120
+class _ModelCard extends ConsumerWidget {
121
+ const _ModelCard({
122
+ required this.model,
123
+ required this.theme,
124
+ required this.downloadedAsync,
125
+ required this.downloadProgress,
126
+ required this.engineState,
127
+ });
128
+
129
+ final OnDeviceModel model;
130
+ final ThemeData theme;
131
+ final AsyncValue<Set<String>> downloadedAsync;
132
+ final DownloadProgressInfo? downloadProgress;
133
+ final OnDeviceEngineState engineState;
134
+
135
+ @override
136
+ Widget build(BuildContext context, WidgetRef ref) {
137
+ final isDownloaded = downloadedAsync.when(
138
+ data: (set) => set.contains(model.id),
139
+ loading: () => false,
140
+ error: (_, __) => false,
141
+ );
142
+
143
+ final isLoading =
144
+ engineState.status == EngineStatus.loading &&
145
+ engineState.loadedModelId == model.id;
146
+
147
+ final isDownloading =
148
+ downloadProgress != null &&
149
+ (downloadProgress!.status == DownloadStatus.running ||
150
+ downloadProgress!.status == DownloadStatus.pending);
151
+
152
+ final isPaused = downloadProgress?.status == DownloadStatus.paused;
153
+
154
+ return Padding(
155
+ padding: const EdgeInsets.only(bottom: 12),
156
+ child: Container(
157
+ padding: const EdgeInsets.all(16),
158
+ decoration: BoxDecoration(
159
+ color: theme.colorScheme.surface,
160
+ borderRadius: BorderRadius.circular(12),
161
+ border: Border.all(
162
+ color: isDownloaded
163
+ ? Colors.green.withValues(alpha: 0.5)
164
+ : theme.colorScheme.outline.withValues(alpha: 0.3),
165
+ width: isDownloaded ? 1.5 : 1,
166
+ ),
167
+ ),
168
+ child: Column(
169
+ crossAxisAlignment: CrossAxisAlignment.start,
170
+ children: [
171
+ Row(
172
+ children: [
173
+ Expanded(
174
+ child: Text(
175
+ model.name,
176
+ style: theme.textTheme.titleSmall?.copyWith(
177
+ fontWeight: FontWeight.bold,
178
+ ),
179
+ ),
180
+ ),
181
+ if (model.isRecommended)
182
+ Container(
183
+ padding: const EdgeInsets.symmetric(
184
+ horizontal: 8,
185
+ vertical: 2,
186
+ ),
187
+ decoration: BoxDecoration(
188
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
189
+ borderRadius: BorderRadius.circular(4),
190
+ ),
191
+ child: Text(
192
+ 'RECOMMENDED',
193
+ style: theme.textTheme.labelSmall?.copyWith(
194
+ color: theme.colorScheme.primary,
195
+ fontWeight: FontWeight.bold,
196
+ ),
197
+ ),
198
+ ),
199
+ ],
200
+ ),
201
+ const SizedBox(height: 4),
202
+ Text(
203
+ model.description,
204
+ style: theme.textTheme.bodySmall?.copyWith(
205
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
206
+ ),
207
+ ),
208
+ const SizedBox(height: 8),
209
+ Row(
210
+ children: [
211
+ Text(
212
+ model.fileSizeFormatted,
213
+ style: theme.textTheme.labelMedium,
214
+ ),
215
+ const SizedBox(width: 12),
216
+ Text(model.license, style: theme.textTheme.labelMedium),
217
+ const SizedBox(width: 12),
218
+ Text(
219
+ '${model.minRamMb ~/ 1024 + 1} GB RAM min',
220
+ style: theme.textTheme.labelMedium?.copyWith(
221
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
222
+ ),
223
+ ),
224
+ ],
225
+ ),
226
+ const SizedBox(height: 8),
227
+ _buildActionRow(
228
+ context,
229
+ ref,
230
+ isDownloaded,
231
+ isLoading,
232
+ isDownloading,
233
+ isPaused,
234
+ ),
235
+ ],
236
+ ),
237
+ ),
238
+ );
239
+ }
240
+
241
+ Widget _buildActionRow(
242
+ BuildContext context,
243
+ WidgetRef ref,
244
+ bool isDownloaded,
245
+ bool isLoading,
246
+ bool isDownloading,
247
+ bool isPaused,
248
+ ) {
249
+ final isLoaded = engineState.loadedModelId == model.id;
250
+
251
+ if (isDownloaded) {
252
+ return Row(
253
+ children: [
254
+ Icon(
255
+ isLoaded ? Icons.check_circle : Icons.check_circle_outline,
256
+ color: isLoaded ? Colors.green : Colors.grey,
257
+ size: 18,
258
+ ),
259
+ const SizedBox(width: 4),
260
+ Text(
261
+ isLoaded ? 'Loaded' : 'Downloaded',
262
+ style: theme.textTheme.bodySmall?.copyWith(
263
+ color: isLoaded ? Colors.green : Colors.grey,
264
+ fontWeight: FontWeight.w600,
265
+ ),
266
+ ),
267
+ const Spacer(),
268
+ if (isLoading)
269
+ const SizedBox(
270
+ width: 20,
271
+ height: 20,
272
+ child: CircularProgressIndicator(strokeWidth: 2),
273
+ )
274
+ else if (isLoaded)
275
+ ShadButton.outline(
276
+ size: ShadButtonSize.sm,
277
+ onPressed: () => _unloadModel(ref),
278
+ child: const Text('Unload'),
279
+ )
280
+ else
281
+ ShadButton.outline(
282
+ size: ShadButtonSize.sm,
283
+ onPressed: () => _loadModel(ref),
284
+ child: const Text('Load'),
285
+ ),
286
+ const SizedBox(width: 8),
287
+ ShadButton.outline(
288
+ size: ShadButtonSize.sm,
289
+ onPressed: () => _deleteModel(context, ref),
290
+ child: const Text('Delete'),
291
+ ),
292
+ ],
293
+ );
294
+ }
295
+
296
+ if (isDownloading) {
297
+ return Row(
298
+ children: [
299
+ Expanded(
300
+ child: Column(
301
+ crossAxisAlignment: CrossAxisAlignment.start,
302
+ children: [
303
+ LinearProgressIndicator(
304
+ value: downloadProgress?.progress ?? 0.0,
305
+ ),
306
+ const SizedBox(height: 4),
307
+ Text(
308
+ 'Downloading - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
309
+ style: theme.textTheme.labelSmall,
310
+ ),
311
+ ],
312
+ ),
313
+ ),
314
+ const SizedBox(width: 8),
315
+ ShadButton.outline(
316
+ size: ShadButtonSize.sm,
317
+ onPressed: () => ref
318
+ .read(foregroundDownloadNotifierProvider.notifier)
319
+ .cancelDownload(model.id),
320
+ child: const Text('Cancel'),
321
+ ),
322
+ ],
323
+ );
324
+ }
325
+
326
+ if (downloadProgress?.status == DownloadStatus.failed) {
327
+ return Row(
328
+ children: [
329
+ Expanded(
330
+ child: Text(
331
+ downloadProgress?.error ?? 'Download failed',
332
+ style: theme.textTheme.bodySmall?.copyWith(color: Colors.red),
333
+ ),
334
+ ),
335
+ const SizedBox(width: 8),
336
+ ShadButton.outline(
337
+ size: ShadButtonSize.sm,
338
+ onPressed: () => ref
339
+ .read(foregroundDownloadNotifierProvider.notifier)
340
+ .retryDownload(model.id),
341
+ child: const Text('Retry'),
342
+ ),
343
+ ],
344
+ );
345
+ }
346
+
347
+ return Row(
348
+ children: [
349
+ const Spacer(),
350
+ ShadButton.outline(
351
+ size: ShadButtonSize.sm,
352
+ onPressed: () => _startDownload(ref),
353
+ child: const Text('Download'),
354
+ ),
355
+ ],
356
+ );
357
+ }
358
+
359
+ Future<void> _startDownload(WidgetRef ref) async {
360
+ await ref
361
+ .read(foregroundDownloadNotifierProvider.notifier)
362
+ .startDownload(model.id);
363
+ }
364
+
365
+ Future<void> _loadModel(WidgetRef ref) async {
366
+ final settings = ref.read(settingsProvider);
367
+ final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
368
+ await engineNotifier.loadModel(model.id, settings.preferredBackend);
369
+ }
370
+
371
+ Future<void> _unloadModel(WidgetRef ref) async {
372
+ await ref.read(onDeviceEngineProvider.notifier).unloadModel();
373
+ }
374
+
375
+ Future<void> _deleteModel(BuildContext context, WidgetRef ref) async {
376
+ final confirm = await showDialog<bool>(
377
+ context: context,
378
+ builder: (ctx) => AlertDialog(
379
+ title: const Text('Delete Model'),
380
+ content: Text('Are you sure you want to delete ${model.name}?'),
381
+ actions: [
382
+ TextButton(
383
+ onPressed: () => Navigator.pop(ctx, false),
384
+ child: const Text('Cancel'),
385
+ ),
386
+ TextButton(
387
+ onPressed: () => Navigator.pop(ctx, true),
388
+ child: const Text('Delete'),
389
+ ),
390
+ ],
391
+ ),
392
+ );
393
+
394
+ if (confirm == true) {
395
+ final engineState = ref.read(onDeviceEngineProvider);
396
+ if (engineState.loadedModelId == model.id) {
397
+ await ref.read(onDeviceEngineProvider.notifier).unloadModel();
398
+ }
399
+
400
+ final downloadService = ref.read(onDeviceDownloadServiceProvider);
401
+ await downloadService.deleteModel(model.id);
402
+ ref.invalidate(downloadedModelsProvider);
403
+ }
404
+ }
405
+}
lib/features/onboarding/screens/onboarding_model_download_screen.dart
new
+312
@@ -0,0 +1,312 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import 'package:flutter/material.dart';
5
+import 'package:flutter_riverpod/flutter_riverpod.dart';
6
+import 'package:go_router/go_router.dart';
7
+import 'package:shadcn_ui/shadcn_ui.dart';
8
+
9
+import '../../../core/models/enums.dart';
10
+import '../../../core/routes/app_routes.dart';
11
+import '../../on_device/data/models/on_device_model.dart';
12
+import '../../on_device/data/on_device_model_download_service.dart';
13
+import '../../on_device/providers/on_device_providers.dart';
14
+import '../../servers/data/models/server.dart';
15
+import '../../servers/providers/server_providers.dart';
16
+
17
+class OnboardingModelDownloadScreen extends ConsumerStatefulWidget {
18
+ const OnboardingModelDownloadScreen({super.key});
19
+
20
+ @override
21
+ ConsumerState<OnboardingModelDownloadScreen> createState() =>
22
+ _OnboardingModelDownloadScreenState();
23
+}
24
+
25
+class _OnboardingModelDownloadScreenState
26
+ extends ConsumerState<OnboardingModelDownloadScreen> {
27
+ String? _downloadingModelId;
28
+ double _downloadProgress = 0.0;
29
+ bool _isCreatingServer = false;
30
+ StreamSubscription<DownloadProgress>? _downloadSubscription;
31
+
32
+ @override
33
+ Widget build(BuildContext context) {
34
+ final theme = Theme.of(context);
35
+ final models = ref.watch(onDeviceModelsProvider);
36
+ final downloadedModelsAsync = ref.watch(downloadedModelsProvider);
37
+ final isAndroid = Platform.isAndroid;
38
+
39
+ return Scaffold(
40
+ appBar: AppBar(title: const Text('Download a Model')),
41
+ body: SafeArea(
42
+ child: ListView(
43
+ padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
44
+ children: [
45
+ Text(
46
+ 'Choose a model to download.\nIt will run locally on your device.',
47
+ style: theme.textTheme.bodyMedium?.copyWith(
48
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
49
+ ),
50
+ ),
51
+ const SizedBox(height: 24),
52
+ if (!isAndroid)
53
+ Container(
54
+ padding: const EdgeInsets.all(12),
55
+ margin: const EdgeInsets.only(bottom: 16),
56
+ decoration: BoxDecoration(
57
+ color: Colors.orange.withValues(alpha: 0.1),
58
+ borderRadius: BorderRadius.circular(8),
59
+ border: Border.all(color: Colors.orange),
60
+ ),
61
+ child: const Row(
62
+ children: [
63
+ Icon(Icons.info_outline, color: Colors.orange, size: 20),
64
+ SizedBox(width: 8),
65
+ Expanded(
66
+ child: Text(
67
+ 'On-device inference is currently available on Android only.',
68
+ style: TextStyle(color: Colors.orange),
69
+ ),
70
+ ),
71
+ ],
72
+ ),
73
+ ),
74
+ ...models.map((model) {
75
+ final isDownloaded = downloadedModelsAsync.when(
76
+ data: (set) => set.contains(model.id),
77
+ loading: () => false,
78
+ error: (_, __) => false,
79
+ );
80
+ final isDownloading = _downloadingModelId == model.id;
81
+ return Padding(
82
+ padding: const EdgeInsets.only(bottom: 12),
83
+ child: _ModelCard(
84
+ model: model,
85
+ isDownloaded: isDownloaded,
86
+ isDownloading: isDownloading,
87
+ downloadProgress: _downloadProgress,
88
+ onDownload: isDownloaded || isDownloading
89
+ ? null
90
+ : () => _downloadModel(model),
91
+ theme: theme,
92
+ ),
93
+ );
94
+ }),
95
+ const SizedBox(height: 24),
96
+ if (_isCreatingServer)
97
+ const Center(child: CircularProgressIndicator())
98
+ else
99
+ ShadButton(
100
+ width: double.infinity,
101
+ onPressed: _canContinue() ? _createOnDeviceServer : null,
102
+ child: const Text(
103
+ 'Continue',
104
+ style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
105
+ ),
106
+ ),
107
+ ],
108
+ ),
109
+ ),
110
+ );
111
+ }
112
+
113
+ bool _canContinue() {
114
+ final downloadedSet = ref.read(downloadedModelsProvider).whenData((s) => s);
115
+ return downloadedSet.hasValue && downloadedSet.value!.isNotEmpty;
116
+ }
117
+
118
+ Future<void> _downloadModel(OnDeviceModel model) async {
119
+ await _downloadSubscription?.cancel();
120
+ setState(() {
121
+ _downloadingModelId = model.id;
122
+ _downloadProgress = 0.0;
123
+ });
124
+
125
+ final downloadService = ref.read(onDeviceDownloadServiceProvider);
126
+ try {
127
+ await for (final progress in downloadService.downloadModel(model)) {
128
+ if (!mounted) return;
129
+ setState(() {
130
+ _downloadProgress = progress.fraction;
131
+ });
132
+ }
133
+ ref.invalidate(downloadedModelsProvider);
134
+ if (mounted) {
135
+ setState(() {
136
+ _downloadingModelId = null;
137
+ });
138
+ }
139
+ } catch (e) {
140
+ if (mounted) {
141
+ setState(() {
142
+ _downloadingModelId = null;
143
+ });
144
+ ScaffoldMessenger.of(context).showSnackBar(
145
+ SnackBar(content: Text('Download failed: ${e.toString()}')),
146
+ );
147
+ }
148
+ }
149
+ }
150
+
151
+ Future<void> _createOnDeviceServer() async {
152
+ setState(() => _isCreatingServer = true);
153
+
154
+ try {
155
+ final server = Server(
156
+ id: DateTime.now().millisecondsSinceEpoch.toString(),
157
+ name: 'On-Device',
158
+ type: ServerType.onDevice,
159
+ host: '',
160
+ port: 0,
161
+ isDefault: true,
162
+ createdAt: DateTime.now(),
163
+ lastConnectedAt: DateTime.now(),
164
+ status: ConnectionStatus.connected,
165
+ iconName: 'strokeRoundedSmartPhone01',
166
+ );
167
+
168
+ await ref.read(serversProvider.notifier).addServer(server);
169
+ await ref.read(serversProvider.notifier).setDefault(server.id);
170
+ ref.read(activeServerProvider.notifier).setActiveServer(server);
171
+
172
+ if (mounted) {
173
+ context.push(AppRoutes.onboardingTheme);
174
+ }
175
+ } catch (e) {
176
+ if (mounted) {
177
+ ScaffoldMessenger.of(
178
+ context,
179
+ ).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}')));
180
+ }
181
+ } finally {
182
+ if (mounted) {
183
+ setState(() => _isCreatingServer = false);
184
+ }
185
+ }
186
+ }
187
+}
188
+
189
+class _ModelCard extends StatelessWidget {
190
+ final OnDeviceModel model;
191
+ final bool isDownloaded;
192
+ final bool isDownloading;
193
+ final double downloadProgress;
194
+ final VoidCallback? onDownload;
195
+ final ThemeData theme;
196
+
197
+ const _ModelCard({
198
+ required this.model,
199
+ required this.isDownloaded,
200
+ required this.isDownloading,
201
+ required this.downloadProgress,
202
+ this.onDownload,
203
+ required this.theme,
204
+ });
205
+
206
+ @override
207
+ Widget build(BuildContext context) {
208
+ return Container(
209
+ padding: const EdgeInsets.all(16),
210
+ decoration: BoxDecoration(
211
+ color: theme.colorScheme.surface,
212
+ borderRadius: BorderRadius.circular(12),
213
+ border: Border.all(
214
+ color: isDownloaded
215
+ ? Colors.green.withValues(alpha: 0.5)
216
+ : theme.colorScheme.outline.withValues(alpha: 0.3),
217
+ width: isDownloaded ? 1.5 : 1,
218
+ ),
219
+ ),
220
+ child: Column(
221
+ crossAxisAlignment: CrossAxisAlignment.start,
222
+ children: [
223
+ Row(
224
+ children: [
225
+ Expanded(
226
+ child: Text(
227
+ model.name,
228
+ style: theme.textTheme.titleSmall?.copyWith(
229
+ fontWeight: FontWeight.bold,
230
+ ),
231
+ ),
232
+ ),
233
+ if (model.isRecommended)
234
+ Container(
235
+ padding: const EdgeInsets.symmetric(
236
+ horizontal: 8,
237
+ vertical: 2,
238
+ ),
239
+ decoration: BoxDecoration(
240
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
241
+ borderRadius: BorderRadius.circular(4),
242
+ ),
243
+ child: Text(
244
+ 'RECOMMENDED',
245
+ style: theme.textTheme.labelSmall?.copyWith(
246
+ color: theme.colorScheme.primary,
247
+ fontWeight: FontWeight.bold,
248
+ ),
249
+ ),
250
+ ),
251
+ ],
252
+ ),
253
+ const SizedBox(height: 4),
254
+ Text(
255
+ model.description,
256
+ style: theme.textTheme.bodySmall?.copyWith(
257
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
258
+ ),
259
+ ),
260
+ const SizedBox(height: 8),
261
+ Row(
262
+ children: [
263
+ Text(model.fileSizeFormatted, style: theme.textTheme.labelMedium),
264
+ const SizedBox(width: 12),
265
+ Text(model.license, style: theme.textTheme.labelMedium),
266
+ const SizedBox(width: 12),
267
+ Text(
268
+ '${model.minRamMb ~/ 1024 + 1} GB RAM min',
269
+ style: theme.textTheme.labelMedium?.copyWith(
270
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
271
+ ),
272
+ ),
273
+ ],
274
+ ),
275
+ const SizedBox(height: 8),
276
+ if (isDownloaded)
277
+ Row(
278
+ children: [
279
+ const Icon(Icons.check_circle, color: Colors.green, size: 18),
280
+ const SizedBox(width: 4),
281
+ Text(
282
+ 'Downloaded',
283
+ style: theme.textTheme.bodySmall?.copyWith(
284
+ color: Colors.green,
285
+ fontWeight: FontWeight.w600,
286
+ ),
287
+ ),
288
+ ],
289
+ )
290
+ else if (isDownloading)
291
+ Column(
292
+ crossAxisAlignment: CrossAxisAlignment.stretch,
293
+ children: [
294
+ LinearProgressIndicator(value: downloadProgress),
295
+ const SizedBox(height: 4),
296
+ Text(
297
+ '${(downloadProgress * 100).toStringAsFixed(0)}%',
298
+ style: theme.textTheme.labelSmall,
299
+ ),
300
+ ],
301
+ )
302
+ else
303
+ ShadButton.outline(
304
+ size: ShadButtonSize.sm,
305
+ onPressed: onDownload,
306
+ child: const Text('Download'),
307
+ ),
308
+ ],
309
+ ),
310
+ );
311
+ }
312
+}
lib/features/onboarding/screens/onboarding_server_setup_screen.dart
+4
@@ -58,6 +58,10 @@ class _OnboardingServerSetupScreenState
58
defaultName = 'OpenRouter';
59
defaultPort = '443';
60
break;
61
+ case ServerType.onDevice:
62
+ defaultName = 'On-Device';
63
+ defaultPort = '0';
64
+ break;
65
}
66
67
_nameController = TextEditingController(text: defaultName);
lib/features/onboarding/screens/onboarding_server_type_screen.dart
+37
-5
@@ -5,6 +5,7 @@ import 'package:hugeicons/hugeicons.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
6
7
import '../../../core/models/enums.dart';
8
+import '../../../core/providers/app_providers.dart';
9
import '../../../core/routes/app_routes.dart';
10
11
class OnboardingServerTypeScreen extends ConsumerStatefulWidget {
@@ -44,7 +45,7 @@ class _OnboardingServerTypeScreenState
45
centerTitle: false,
46
actions: [
47
TextButton.icon(
47
- onPressed: () {},
48
+ onPressed: () => _skipOnboarding(),
49
label: const Text('Skip'),
50
icon: HugeIcon(icon: HugeIcons.strokeRoundedNext),
51
),
@@ -78,6 +79,19 @@ class _OnboardingServerTypeScreenState
79
),
80
),
81
const SizedBox(height: 24),
82
+ _buildServerCard(
83
+ type: ServerType.onDevice,
84
+ title: 'On-Device',
85
+ subtitle: 'NO SERVER NEEDED',
86
+ iconWidget: Icon(
87
+ Icons.phone_android_rounded,
88
+ color: _selectedType == ServerType.onDevice
89
+ ? theme.colorScheme.primary
90
+ : theme.colorScheme.onSurface,
91
+ ),
92
+ theme: theme,
93
+ ),
94
+ const SizedBox(height: 16),
95
_buildServerCard(
96
type: ServerType.lmStudio,
97
title: 'LM Studio',
@@ -162,10 +176,14 @@ class _OnboardingServerTypeScreenState
176
enabled: _selectedType != null,
177
onPressed: () {
178
if (_selectedType != null) {
165
- context.push(
166
- AppRoutes.onboardingSetup,
167
- extra: _selectedType,
168
- );
179
+ if (_selectedType == ServerType.onDevice) {
180
+ _completeOnboarding();
181
+ } else {
182
+ context.push(
183
+ AppRoutes.onboardingSetup,
184
+ extra: _selectedType,
185
+ );
186
+ }
187
}
188
},
189
child: const Text(
@@ -242,4 +260,18 @@ class _OnboardingServerTypeScreenState
260
),
261
);
262
}
263
+
264
+ Future<void> _completeOnboarding() async {
265
+ final settings = ref.read(settingsProvider);
266
+ await ref
267
+ .read(settingsProvider.notifier)
268
+ .updateSettings(settings.copyWith(hasCompletedOnboarding: true));
269
+ if (mounted) {
270
+ context.go(AppRoutes.home);
271
+ }
272
+ }
273
+
274
+ void _skipOnboarding() {
275
+ _completeOnboarding();
276
+ }
277
}
lib/features/servers/data/models/server.dart
+15
@@ -31,6 +31,9 @@ class Server {
31
if (type == ServerType.openRouter) {
32
return 'https://openrouter.ai/api/v1';
33
}
34
+ if (type == ServerType.onDevice) {
35
+ return 'on-device';
36
+ }
37
return 'http://$host:$port';
38
}
39
@@ -44,6 +47,8 @@ class Server {
47
return '$baseUrl/api/chat';
48
case ServerType.openRouter:
49
return '$baseUrl/chat/completions';
50
+ case ServerType.onDevice:
51
+ return '';
52
}
53
}
54
@@ -57,6 +62,8 @@ class Server {
62
return '$baseUrl/api/tags';
63
case ServerType.openRouter:
64
return '$baseUrl/models';
65
+ case ServerType.onDevice:
66
+ return '';
67
}
68
}
69
@@ -70,6 +77,8 @@ class Server {
77
return '$baseUrl/api/ps';
78
case ServerType.openRouter:
79
return '';
80
+ case ServerType.onDevice:
81
+ return '';
82
}
83
}
84
@@ -83,6 +92,8 @@ class Server {
92
return '$baseUrl/api/generate';
93
case ServerType.openRouter:
94
return '';
95
+ case ServerType.onDevice:
96
+ return '';
97
}
98
}
99
@@ -96,9 +107,13 @@ class Server {
107
return '$baseUrl/api/generate';
108
case ServerType.openRouter:
109
return '';
110
+ case ServerType.onDevice:
111
+ return '';
112
}
113
}
114
115
+ bool get isOnDevice => type == ServerType.onDevice;
116
+
117
Server copyWith({
118
String? id,
119
String? name,
lib/features/servers/data/server_api_service.dart
+16
-3
@@ -53,6 +53,8 @@ class ServerApiService {
53
return _parseOllamaModels(response.data, server);
54
case ServerType.openRouter:
55
return _parseOpenRouterModels(response.data, server);
56
+ case ServerType.onDevice:
57
+ return [];
58
}
59
} catch (e) {
60
throw Exception('Failed to fetch models: $e');
@@ -78,6 +80,8 @@ class ServerApiService {
80
return _parseRunningOllamaModels(response.data);
81
case ServerType.openRouter:
82
return {};
83
+ case ServerType.onDevice:
84
+ return {};
85
}
86
} catch (e) {
87
return {};
@@ -85,7 +89,9 @@ class ServerApiService {
89
}
90
91
Future<void> loadModel(Server server, String modelId) async {
88
- if (server.type == ServerType.openRouter) return;
92
+ if (server.type == ServerType.openRouter ||
93
+ server.type == ServerType.onDevice)
94
+ return;
95
96
switch (server.type) {
97
case ServerType.lmStudio:
@@ -97,12 +103,15 @@ class ServerApiService {
103
options: Options(headers: _getAuthHeaders(server)),
104
);
105
case ServerType.openRouter:
106
+ case ServerType.onDevice:
107
break;
108
}
109
}
110
111
Future<String?> loadModelWithInstanceId(Server server, String modelId) async {
105
- if (server.type == ServerType.openRouter) return null;
112
+ if (server.type == ServerType.openRouter ||
113
+ server.type == ServerType.onDevice)
114
+ return null;
115
116
switch (server.type) {
117
case ServerType.lmStudio:
@@ -121,6 +130,7 @@ class ServerApiService {
130
);
131
return null;
132
case ServerType.openRouter:
133
+ case ServerType.onDevice:
134
return null;
135
}
136
}
@@ -130,7 +140,9 @@ class ServerApiService {
140
String modelId, {
141
String? instanceId,
142
}) async {
133
- if (server.type == ServerType.openRouter) return;
143
+ if (server.type == ServerType.openRouter ||
144
+ server.type == ServerType.onDevice)
145
+ return;
146
147
switch (server.type) {
148
case ServerType.lmStudio:
@@ -147,6 +159,7 @@ class ServerApiService {
159
options: Options(headers: _getAuthHeaders(server)),
160
);
161
case ServerType.openRouter:
162
+ case ServerType.onDevice:
163
break;
164
}
165
}
lib/features/servers/providers/server_providers.dart
+36
@@ -11,6 +11,38 @@ import '../../../objectbox.g.dart';
11
12
final _modelCache = ModelCache();
13
14
+bool _onDeviceServerEnsured = false;
15
+
16
+final ensureOnDeviceServerProvider = FutureProvider<void>((ref) async {
17
+ if (_onDeviceServerEnsured) return;
18
+
19
+ final serversAsync = ref.watch(serversProvider);
20
+
21
+ if (!serversAsync.hasValue) return;
22
+
23
+ final servers = serversAsync.value!;
24
+ final hasOnDevice = servers.any((s) => s.type == ServerType.onDevice);
25
+ if (hasOnDevice) {
26
+ _onDeviceServerEnsured = true;
27
+ return;
28
+ }
29
+
30
+ final server = Server(
31
+ id: 'on-device',
32
+ name: 'On-Device',
33
+ type: ServerType.onDevice,
34
+ host: '',
35
+ port: 0,
36
+ isDefault: false,
37
+ createdAt: DateTime.now(),
38
+ lastConnectedAt: DateTime.now(),
39
+ status: ConnectionStatus.connected,
40
+ iconName: 'strokeRoundedSmartPhone01',
41
+ );
42
+ await ref.read(serversProvider.notifier).addServer(server);
43
+ _onDeviceServerEnsured = true;
44
+});
45
+
46
final serversProvider = AsyncNotifierProvider<ServersNotifier, List<Server>>(
47
() {
48
return ServersNotifier();
@@ -218,6 +250,10 @@ class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
250
return ConnectionStatus.disconnected;
251
}
252
253
+ if (activeServer.type == ServerType.onDevice) {
254
+ return ConnectionStatus.connected;
255
+ }
256
+
257
_checkConnection(activeServer, apiService);
258
return ConnectionStatus.checking;
259
}
lib/features/servers/views/components/server_card.dart
+5
@@ -40,6 +40,8 @@ class ServerCard extends StatelessWidget {
40
return 'Ollama';
41
case ServerType.openRouter:
42
return 'OpenRouter';
43
+ case ServerType.onDevice:
44
+ return 'On-Device';
45
}
46
}
47
@@ -47,6 +49,9 @@ class ServerCard extends StatelessWidget {
49
if (server.type == ServerType.openRouter) {
50
return 'openrouter.ai';
51
}
52
+ if (server.type == ServerType.onDevice) {
53
+ return 'Local inference';
54
+ }
55
return '${server.host}:${server.port}';
56
}
57
lib/features/servers/views/server_list_screen.dart
+17
-7
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:go_router/go_router.dart';
4
5
+import '../../../core/models/enums.dart';
6
import '../../../core/providers/service_providers.dart';
7
import '../../../core/routes/app_routes.dart';
8
import '../providers/server_providers.dart';
@@ -77,6 +78,8 @@ class ServerListScreen extends ConsumerWidget {
78
itemCount: serverList.length,
79
itemBuilder: (context, index) {
80
final server = serverList[index];
81
+ final isOnDevice =
82
+ server.type == ServerType.onDevice;
83
return Padding(
84
padding: const EdgeInsets.only(bottom: 12),
85
child: ServerCard(
@@ -95,13 +98,20 @@ class ServerListScreen extends ConsumerWidget {
98
),
99
);
100
},
98
- onEdit: () =>
99
- _showEditDialog(context, ref, server),
100
- onDelete: () => _showDeleteConfirmation(
101
- context,
102
- ref,
103
- server,
104
- ),
101
+ onEdit: isOnDevice
102
+ ? null
103
+ : () => _showEditDialog(
104
+ context,
105
+ ref,
106
+ server,
107
+ ),
108
+ onDelete: isOnDevice
109
+ ? null
110
+ : () => _showDeleteConfirmation(
111
+ context,
112
+ ref,
113
+ server,
114
+ ),
115
onSetDefault: () {
116
ref
117
.read(serversProvider.notifier)
lib/features/settings/data/models/app_settings.dart
+8
@@ -2,6 +2,8 @@ import 'dart:convert';
2
3
import 'package:flutter/material.dart';
4
5
+import '../../../../core/models/enums.dart';
6
+
7
enum SyntaxThemeName {
8
vscodeDark,
9
vscodeLight,
@@ -35,6 +37,7 @@ class AppSettings {
37
final bool mcpEnabled;
38
final SyntaxThemeName codeThemeDark;
39
final SyntaxThemeName codeThemeLight;
40
+ final LiteLmBackendType preferredBackend;
41
42
AppSettings({
43
this.temperature = 0.7,
@@ -55,6 +58,7 @@ class AppSettings {
58
this.mcpEnabled = true,
59
this.codeThemeDark = SyntaxThemeName.vscodeDark,
60
this.codeThemeLight = SyntaxThemeName.vscodeLight,
61
+ this.preferredBackend = LiteLmBackendType.cpu,
62
});
63
64
AppSettings copyWith({
@@ -76,6 +80,7 @@ class AppSettings {
80
bool? mcpEnabled,
81
SyntaxThemeName? codeThemeDark,
82
SyntaxThemeName? codeThemeLight,
83
+ LiteLmBackendType? preferredBackend,
84
}) {
85
return AppSettings(
86
temperature: temperature ?? this.temperature,
@@ -98,6 +103,7 @@ class AppSettings {
103
mcpEnabled: mcpEnabled ?? this.mcpEnabled,
104
codeThemeDark: codeThemeDark ?? this.codeThemeDark,
105
codeThemeLight: codeThemeLight ?? this.codeThemeLight,
106
+ preferredBackend: preferredBackend ?? this.preferredBackend,
107
);
108
}
109
@@ -121,6 +127,7 @@ class AppSettings {
127
'mcpEnabled': mcpEnabled,
128
'codeThemeDark': codeThemeDark.index,
129
'codeThemeLight': codeThemeLight.index,
130
+ 'preferredBackend': preferredBackend.index,
131
};
132
}
133
@@ -144,6 +151,7 @@ class AppSettings {
151
mcpEnabled: map['mcpEnabled'] ?? true,
152
codeThemeDark: SyntaxThemeName.values[map['codeThemeDark'] ?? 0],
153
codeThemeLight: SyntaxThemeName.values[map['codeThemeLight'] ?? 1],
154
+ preferredBackend: LiteLmBackendType.values[map['preferredBackend'] ?? 0],
155
);
156
}
157
lib/features/settings/screens/settings_screen.dart
+100
-1
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:go_router/go_router.dart';
4
import '../../../core/providers/app_providers.dart';
5
-
5
+import '../../../core/models/enums.dart';
6
+import '../../../core/routes/app_routes.dart';
7
import '../../../core/theme/app_theme.dart';
8
import '../../conversations/providers/conversation_providers.dart';
9
+import '../../on_device/providers/on_device_providers.dart';
10
import '../../servers/providers/server_providers.dart';
11
import '../data/models/app_settings.dart';
12
import '../../personas/providers/personas_providers.dart';
@@ -143,6 +145,23 @@ class SettingsScreen extends ConsumerWidget {
145
isDark: isDark,
146
),
147
const Divider(height: 32),
148
+ _SectionHeader(title: 'On-Device Inference'),
149
+ Padding(
150
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
151
+ child: OutlinedButton.icon(
152
+ onPressed: () => context.push(AppRoutes.onDeviceModels),
153
+ icon: const Icon(Icons.phone_android, size: 18),
154
+ label: const Text('Manage On-Device Models'),
155
+ style: OutlinedButton.styleFrom(
156
+ minimumSize: const Size(double.infinity, 44),
157
+ shape: RoundedRectangleBorder(
158
+ borderRadius: BorderRadius.circular(8),
159
+ ),
160
+ ),
161
+ ),
162
+ ),
163
+ const _OnDeviceEngineStatus(),
164
+ const Divider(height: 32),
165
_SectionHeader(title: 'Default Server'),
166
_DropdownSetting(
167
label: 'Default Server',
@@ -890,3 +909,83 @@ class _DangerousAction extends StatelessWidget {
909
);
910
}
911
}
912
+
913
+class _OnDeviceEngineStatus extends ConsumerWidget {
914
+ const _OnDeviceEngineStatus();
915
+
916
+ @override
917
+ Widget build(BuildContext context, WidgetRef ref) {
918
+ final engineState = ref.watch(onDeviceEngineProvider);
919
+ final isDark = Theme.of(context).brightness == Brightness.dark;
920
+
921
+ if (engineState.status == EngineStatus.notLoaded) {
922
+ return Padding(
923
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
924
+ child: Text(
925
+ 'No model loaded. Tap "Manage On-Device Models" to download and load a model.',
926
+ style: TextStyle(
927
+ fontSize: 12,
928
+ color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
929
+ ),
930
+ ),
931
+ );
932
+ }
933
+
934
+ return Padding(
935
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
936
+ child: Container(
937
+ padding: const EdgeInsets.all(12),
938
+ decoration: BoxDecoration(
939
+ color: engineState.status == EngineStatus.loaded
940
+ ? Colors.green.withValues(alpha: 0.1)
941
+ : engineState.status == EngineStatus.error
942
+ ? Colors.red.withValues(alpha: 0.1)
943
+ : Colors.blue.withValues(alpha: 0.1),
944
+ borderRadius: BorderRadius.circular(8),
945
+ border: Border.all(
946
+ color: engineState.status == EngineStatus.loaded
947
+ ? Colors.green
948
+ : engineState.status == EngineStatus.error
949
+ ? Colors.red
950
+ : Colors.blue,
951
+ ),
952
+ ),
953
+ child: Row(
954
+ children: [
955
+ Icon(
956
+ engineState.status == EngineStatus.loaded
957
+ ? Icons.check_circle
958
+ : engineState.status == EngineStatus.error
959
+ ? Icons.error
960
+ : Icons.hourglass_top,
961
+ size: 18,
962
+ color: engineState.status == EngineStatus.loaded
963
+ ? Colors.green
964
+ : engineState.status == EngineStatus.error
965
+ ? Colors.red
966
+ : Colors.blue,
967
+ ),
968
+ const SizedBox(width: 8),
969
+ Expanded(
970
+ child: Text(
971
+ engineState.status == EngineStatus.loaded
972
+ ? 'Loaded: ${engineState.loadedModelId ?? "unknown"} (${engineState.backend?.name ?? "CPU"})'
973
+ : engineState.status == EngineStatus.loading
974
+ ? 'Loading model...'
975
+ : 'Error: ${engineState.error ?? "Unknown error"}',
976
+ style: TextStyle(
977
+ fontSize: 13,
978
+ color: engineState.status == EngineStatus.loaded
979
+ ? Colors.green
980
+ : engineState.status == EngineStatus.error
981
+ ? Colors.red
982
+ : Colors.blue,
983
+ ),
984
+ ),
985
+ ),
986
+ ],
987
+ ),
988
+ ),
989
+ );
990
+ }
991
+}
lib/features/sidebar/sidebar_widget.dart
+15
-6
@@ -32,11 +32,7 @@ class SidebarWidget extends ConsumerWidget {
32
width: 300,
33
decoration: BoxDecoration(
34
color: theme.scaffoldBackgroundColor,
35
- border: Border(
36
- right: BorderSide(
37
- color: theme.colorScheme.outline,
38
- ),
39
- ),
35
+ border: Border(right: BorderSide(color: theme.colorScheme.outline)),
36
),
37
child: SafeArea(
38
child: Column(
@@ -49,7 +45,9 @@ class SidebarWidget extends ConsumerWidget {
45
Expanded(
46
child: groupedConversations.when(
47
data: (grouped) => grouped.isEmpty
52
- ? ConversationEmptyState(isSearching: searchQuery.isNotEmpty)
48
+ ? ConversationEmptyState(
49
+ isSearching: searchQuery.isNotEmpty,
50
+ )
51
: ConversationList(
52
groupedConversations: grouped,
53
activeConversation: activeConversation,
@@ -90,6 +88,17 @@ class SidebarWidget extends ConsumerWidget {
88
context.go(AppRoutes.servers);
89
},
90
),
91
+ DrawerNavItem(
92
+ iconData: HugeIcons.strokeRoundedSmartPhone01,
93
+ label: 'Local Models',
94
+ isSelected: location.startsWith(AppRoutes.onDeviceModels),
95
+ onTap: () {
96
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
97
+ Navigator.pop(context);
98
+ }
99
+ context.go(AppRoutes.onDeviceModels);
100
+ },
101
+ ),
102
DrawerNavItem(
103
iconData: HugeIcons.strokeRoundedCompass01,
104
label: 'Personas',
lib/main.dart
+1
@@ -1,5 +1,6 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
4
import 'package:shared_preferences/shared_preferences.dart';
5
6
import 'app.dart';
pubspec.lock
+8
@@ -326,6 +326,14 @@ packages:
326
url: "https://pub.dev"
327
source: hosted
328
version: "6.0.0"
329
+ flutter_litert_lm:
330
+ dependency: "direct main"
331
+ description:
332
+ name: flutter_litert_lm
333
+ sha256: "222fa9e669c6864c901049930b5639c7276a18b508a38f1fa8697427bde2abb0"
334
+ url: "https://pub.dev"
335
+ source: hosted
336
+ version: "0.3.0"
337
flutter_localizations:
338
dependency: transitive
339
description: flutter
pubspec.yaml
+1
@@ -53,6 +53,7 @@ dependencies:
53
objectbox: ^5.3.1
54
objectbox_flutter_libs: ^5.3.1
55
path: ^1.9.1
56
+ flutter_litert_lm: ^0.3.0
57
58
dev_dependencies:
59
flutter_test: