9 files changed
+844
-197
lib/core/device/device_memory_service.dart
new
+67
@@ -0,0 +1,67 @@
1
+import 'dart:io';
2
+import 'package:device_info_plus/device_info_plus.dart';
3
+
4
+class DeviceMemoryInfo {
5
+ final int totalMemoryMb;
6
+ final int availableMemoryMb;
7
+
8
+ const DeviceMemoryInfo({
9
+ required this.totalMemoryMb,
10
+ required this.availableMemoryMb,
11
+ });
12
+
13
+ bool hasEnoughRam(int requiredMb) => availableMemoryMb >= requiredMb;
14
+ bool isOversized(int minRamMb) => totalMemoryMb < minRamMb;
15
+
16
+ String get totalMemoryFormatted => _formatMb(totalMemoryMb);
17
+ String get availableMemoryFormatted => _formatMb(availableMemoryMb);
18
+
19
+ String _formatMb(int mb) {
20
+ if (mb >= 1024) {
21
+ return '${(mb / 1024).toStringAsFixed(1)} GB';
22
+ }
23
+ return '$mb MB';
24
+ }
25
+}
26
+
27
+class DeviceMemoryService {
28
+ final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
29
+
30
+ Future<DeviceMemoryInfo> getMemoryInfo() async {
31
+ int totalMb = 0;
32
+ int availableMb = 0;
33
+
34
+ if (Platform.isAndroid) {
35
+ try {
36
+ final androidInfo = await _deviceInfo.androidInfo;
37
+ totalMb = androidInfo.physicalRamSize; // In MB
38
+ } catch (_) {}
39
+
40
+ try {
41
+ final file = File('/proc/meminfo');
42
+ if (await file.exists()) {
43
+ final memInfo = await file.readAsLines();
44
+ for (final line in memInfo) {
45
+ if (line.startsWith('MemAvailable:')) {
46
+ final parts = line.split(RegExp(r'\s+'));
47
+ if (parts.length >= 2) {
48
+ final kb = int.tryParse(parts[1]) ?? 0;
49
+ availableMb = kb ~/ 1024;
50
+ }
51
+ break;
52
+ }
53
+ }
54
+ }
55
+ } catch (_) {
56
+ // Fallback or ignore
57
+ }
58
+ } else if (Platform.isMacOS || Platform.isLinux) {
59
+ // Optional: Add support for desktop if needed for testing
60
+ }
61
+
62
+ return DeviceMemoryInfo(
63
+ totalMemoryMb: totalMb,
64
+ availableMemoryMb: availableMb,
65
+ );
66
+ }
67
+}
lib/core/providers/device_info_providers.dart
new
+11
@@ -0,0 +1,11 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import '../device/device_memory_service.dart';
3
+
4
+final deviceMemoryServiceProvider = Provider<DeviceMemoryService>((ref) {
5
+ return DeviceMemoryService();
6
+});
7
+
8
+final deviceMemoryProvider = FutureProvider<DeviceMemoryInfo>((ref) async {
9
+ final service = ref.watch(deviceMemoryServiceProvider);
10
+ return service.getMemoryInfo();
11
+});
lib/features/models/screens/model_picker_sheet.dart
+201
-160
@@ -10,6 +10,8 @@ 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
+import '../../../core/providers/device_info_providers.dart';
14
+import '../../../core/device/device_memory_service.dart';
15
16
final modelSearchQueryProvider = NotifierProvider<_ModelSearchNotifier, String>(
17
_ModelSearchNotifier.new,
@@ -649,6 +651,7 @@ class _OnDeviceModelList extends ConsumerWidget {
651
final downloadedAsync = ref.watch(downloadedModelsProvider);
652
final engineState = ref.watch(onDeviceEngineProvider);
653
final searchQuery = ref.watch(modelSearchQueryProvider);
654
+ final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
655
656
return downloadedAsync.when(
657
loading: () => const Center(child: CircularProgressIndicator()),
@@ -720,6 +723,7 @@ class _OnDeviceModelList extends ConsumerWidget {
723
isCurrentlyLoading: isCurrentlyLoading,
724
isSelected: isSelected,
725
isDark: isDark,
726
+ deviceMemory: deviceMemoryAsync.value,
727
);
728
},
729
);
@@ -736,6 +740,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
740
required this.isCurrentlyLoading,
741
required this.isSelected,
742
required this.isDark,
743
+ this.deviceMemory,
744
});
745
746
final OnDeviceModel model;
@@ -744,6 +749,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
749
final bool isCurrentlyLoading;
750
final bool isSelected;
751
final bool isDark;
752
+ final DeviceMemoryInfo? deviceMemory;
753
754
@override
755
Widget build(BuildContext context, WidgetRef ref) {
@@ -770,190 +776,195 @@ class _OnDeviceModelTile extends ConsumerWidget {
776
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
777
margin: const EdgeInsets.only(bottom: 4),
778
decoration: BoxDecoration(
773
- color: isSelected
774
- ? accent.withValues(alpha: 0.1)
775
- : Colors.transparent,
779
+ color: isSelected ? accent.withValues(alpha: 0.1) : Colors.transparent,
780
borderRadius: BorderRadius.circular(8),
777
- border: isSelected
778
- ? Border.all(color: accent.withValues(alpha: 0.3))
779
- : null,
781
+ border: isSelected ? Border.all(color: accent.withValues(alpha: 0.3)) : null,
782
),
783
child: Row(
784
children: [
783
- Expanded(
784
- child: Column(
785
- crossAxisAlignment: CrossAxisAlignment.start,
786
- children: [
787
- Row(
788
- children: [
789
- Expanded(
790
- child: Text(
791
- model.name,
792
- style: TextStyle(
793
- fontSize: 15,
794
- fontWeight: isSelected
795
- ? FontWeight.w600
796
- : FontWeight.w500,
797
- color: isDownloaded
798
- ? (isDark ? Colors.white : Colors.black)
799
- : (isDark
800
- ? const Color(0xFF555555)
801
- : const Color(0xFF999999)),
802
- ),
803
- maxLines: 1,
804
- overflow: TextOverflow.ellipsis,
805
- ),
806
- ),
807
- if (model.isRecommended)
808
- Container(
809
- padding: const EdgeInsets.symmetric(
810
- horizontal: 6,
811
- vertical: 2,
812
- ),
813
- decoration: BoxDecoration(
814
- color: accent.withValues(alpha: 0.1),
815
- borderRadius: BorderRadius.circular(4),
816
- ),
785
+ Expanded(
786
+ child: Column(
787
+ crossAxisAlignment: CrossAxisAlignment.start,
788
+ children: [
789
+ Row(
790
+ children: [
791
+ Expanded(
792
child: Text(
818
- 'REC',
793
+ model.name,
794
style: TextStyle(
820
- fontSize: 9,
821
- fontWeight: FontWeight.bold,
822
- color: accent,
795
+ fontSize: 15,
796
+ fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
797
+ color: isDownloaded
798
+ ? (isDark ? Colors.white : Colors.black)
799
+ : (isDark ? const Color(0xFF555555) : const Color(0xFF999999)),
800
),
801
+ maxLines: 1,
802
+ overflow: TextOverflow.ellipsis,
803
),
804
),
826
- ],
827
- ),
828
- if (isDownloading || isPaused)
829
- Padding(
830
- padding: const EdgeInsets.only(top: 6),
831
- child: Column(
832
- crossAxisAlignment: CrossAxisAlignment.start,
833
- children: [
834
- LinearProgressIndicator(
835
- value: downloadProgress?.progress ?? 0.0,
836
- backgroundColor: isDark
837
- ? const Color(0xFF333333)
838
- : const Color(0xFFE0E0E0),
839
- ),
840
- const SizedBox(height: 2),
841
- Text(
842
- isPaused
843
- ? 'Paused - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%'
844
- : 'Downloading - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
845
- style: TextStyle(
846
- fontSize: 10,
847
- color: isDark
848
- ? const Color(0xFF888888)
849
- : const Color(0xFF999999),
805
+ if (model.isRecommended)
806
+ Container(
807
+ padding: const EdgeInsets.symmetric(
808
+ horizontal: 6,
809
+ vertical: 2,
810
+ ),
811
+ decoration: BoxDecoration(
812
+ color: accent.withValues(alpha: 0.1),
813
+ borderRadius: BorderRadius.circular(4),
814
+ ),
815
+ child: Text(
816
+ 'REC',
817
+ style: TextStyle(
818
+ fontSize: 9,
819
+ fontWeight: FontWeight.bold,
820
+ color: accent,
821
+ ),
822
),
823
),
852
- ],
853
- ),
824
+ ],
825
),
855
- const SizedBox(height: 4),
856
- Wrap(
857
- spacing: 6,
858
- runSpacing: 4,
859
- children: [
860
- _MetadataChip(
861
- label: model.fileSizeFormatted,
862
- isDark: isDark,
826
+ if (deviceMemory != null && deviceMemory!.isOversized(model.minRamMb))
827
+ Padding(
828
+ padding: const EdgeInsets.only(top: 2),
829
+ child: Row(
830
+ children: [
831
+ const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 12),
832
+ const SizedBox(width: 4),
833
+ Text(
834
+ 'May be too large',
835
+ style: TextStyle(
836
+ fontSize: 10,
837
+ color: Colors.orange,
838
+ fontWeight: FontWeight.bold,
839
+ ),
840
+ ),
841
+ ],
842
+ ),
843
),
864
- _MetadataChip(label: model.license, isDark: isDark),
865
- _MetadataChip(label: model.parameterLabel, isDark: isDark),
866
- ],
867
- ),
868
- ],
869
- ),
870
- ),
871
- const SizedBox(width: 8),
872
- if (isCurrentlyLoading) ...[
873
- const SizedBox(
874
- width: 16,
875
- height: 16,
876
- child: CircularProgressIndicator(strokeWidth: 2),
877
- ),
878
- ] else if (isLoaded) ...[
879
- Container(
880
- width: 10,
881
- height: 10,
882
- decoration: const BoxDecoration(
883
- color: Color(0xFF4CAF50),
884
- shape: BoxShape.circle,
844
+ if (isDownloading || isPaused)
845
+ Padding(
846
+ padding: const EdgeInsets.only(top: 6),
847
+ child: Column(
848
+ crossAxisAlignment: CrossAxisAlignment.start,
849
+ children: [
850
+ LinearProgressIndicator(
851
+ value: downloadProgress?.progress ?? 0.0,
852
+ backgroundColor: isDark ? const Color(0xFF333333) : const Color(0xFFE0E0E0),
853
+ ),
854
+ const SizedBox(height: 2),
855
+ Text(
856
+ isPaused
857
+ ? 'Paused - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%'
858
+ : 'Downloading - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
859
+ style: TextStyle(
860
+ fontSize: 10,
861
+ color: isDark ? const Color(0xFF888888) : const Color(0xFF999999),
862
+ ),
863
+ ),
864
+ ],
865
+ ),
866
+ ),
867
+ const SizedBox(height: 4),
868
+ Wrap(
869
+ spacing: 6,
870
+ runSpacing: 4,
871
+ children: [
872
+ _MetadataChip(
873
+ label: model.fileSizeFormatted,
874
+ isDark: isDark,
875
+ ),
876
+ _MetadataChip(label: model.license, isDark: isDark),
877
+ _MetadataChip(label: model.parameterLabel, isDark: isDark),
878
+ ],
879
+ ),
880
+ ],
881
),
882
),
883
const SizedBox(width: 8),
888
- _IconButton(
889
- icon: Icon(
890
- Icons.power_settings_new_outlined,
891
- size: 18,
892
- color: Colors.red[400],
884
+ if (isCurrentlyLoading) ...[
885
+ const SizedBox(
886
+ width: 16,
887
+ height: 16,
888
+ child: CircularProgressIndicator(strokeWidth: 2),
889
),
894
- tooltip: 'Unload model',
895
- onPressed: () => _unloadModel(context, ref),
896
- ),
897
- ] else if (isDownloaded) ...[
898
- _IconButton(
899
- icon: Icon(Icons.play_arrow, size: 20, color: accent),
900
- tooltip: 'Load model',
901
- onPressed: () => _loadModel(context, ref),
902
- ),
903
- const SizedBox(width: 4),
904
- _IconButton(
905
- icon: Icon(
906
- Icons.delete_outline,
907
- size: 18,
908
- color: isDark
909
- ? const Color(0xFF666666)
910
- : const Color(0xFF999999),
890
+ ] else if (isLoaded) ...[
891
+ Container(
892
+ width: 10,
893
+ height: 10,
894
+ decoration: const BoxDecoration(
895
+ color: Color(0xFF4CAF50),
896
+ shape: BoxShape.circle,
897
+ ),
898
),
912
- tooltip: 'Delete model',
913
- onPressed: () => _deleteModel(context, ref),
914
- ),
915
- ] else if (isDownloading) ...[
916
- _IconButton(
917
- icon: Icon(
918
- Icons.close,
919
- size: 16,
920
- color: isDark
921
- ? const Color(0xFF666666)
922
- : const Color(0xFF999999),
899
+ const SizedBox(width: 8),
900
+ _IconButton(
901
+ icon: Icon(
902
+ Icons.power_settings_new_outlined,
903
+ size: 18,
904
+ color: Colors.red[400],
905
+ ),
906
+ tooltip: 'Unload model',
907
+ onPressed: () => _unloadModel(context, ref),
908
),
924
- tooltip: 'Cancel download',
925
- onPressed: () => ref
926
- .read(foregroundDownloadNotifierProvider.notifier)
927
- .cancelDownload(model.id),
928
- ),
929
- ] else ...[
930
- Icon(
931
- Icons.cloud_download_outlined,
932
- size: 18,
933
- color: isDark ? const Color(0xFF555555) : const Color(0xFF999999),
934
- ),
935
- const SizedBox(width: 4),
936
- Text(
937
- 'Not downloaded',
938
- style: TextStyle(
939
- fontSize: 11,
940
- color: isDark
941
- ? const Color(0xFF666666)
942
- : const Color(0xFF999999),
909
+ ] else if (isDownloaded) ...[
910
+ _IconButton(
911
+ icon: Icon(Icons.play_arrow, size: 20, color: accent),
912
+ tooltip: 'Load model',
913
+ onPressed: () => _loadModel(context, ref),
914
),
944
- ),
945
- ],
946
- if (isDownloaded && isSelected && isLoaded) ...[
947
- const SizedBox(width: 4),
948
- Icon(Icons.check_circle, color: accent, size: 22),
915
+ const SizedBox(width: 4),
916
+ _IconButton(
917
+ icon: Icon(
918
+ Icons.delete_outline,
919
+ size: 18,
920
+ color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
921
+ ),
922
+ tooltip: 'Delete model',
923
+ onPressed: () => _deleteModel(context, ref),
924
+ ),
925
+ ] else if (isDownloading) ...[
926
+ _IconButton(
927
+ icon: Icon(
928
+ Icons.close,
929
+ size: 16,
930
+ color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
931
+ ),
932
+ tooltip: 'Cancel download',
933
+ onPressed: () => ref.read(foregroundDownloadNotifierProvider.notifier).cancelDownload(model.id),
934
+ ),
935
+ ] else ...[
936
+ Icon(
937
+ Icons.cloud_download_outlined,
938
+ size: 18,
939
+ color: isDark ? const Color(0xFF555555) : const Color(0xFF999999),
940
+ ),
941
+ const SizedBox(width: 4),
942
+ Text(
943
+ 'Not downloaded',
944
+ style: TextStyle(
945
+ fontSize: 11,
946
+ color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
947
+ ),
948
+ ),
949
+ ],
950
+ if (isDownloaded && isSelected && isLoaded) ...[
951
+ const SizedBox(width: 4),
952
+ Icon(Icons.check_circle, color: accent, size: 22),
953
+ ],
954
],
950
- ],
955
+ ),
956
),
952
- ),
953
- );
954
-}
957
+ );
958
+ }
959
960
void _loadModel(BuildContext context, WidgetRef ref) async {
961
+ if (deviceMemory != null) {
962
+ if (!deviceMemory!.hasEnoughRam(model.minRamMb)) {
963
+ final proceed = await _showRamWarning(context);
964
+ if (!proceed) return;
965
+ }
966
+ }
967
+
968
final settings = ref.read(settingsProvider);
969
final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
970
@@ -979,6 +990,36 @@ class _OnDeviceModelTile extends ConsumerWidget {
990
}
991
}
992
993
+ Future<bool> _showRamWarning(BuildContext context) async {
994
+ final result = await showDialog<bool>(
995
+ context: context,
996
+ builder: (ctx) => AlertDialog(
997
+ title: const Row(
998
+ children: [
999
+ Icon(Icons.warning_amber_rounded, color: Colors.orange),
1000
+ SizedBox(width: 8),
1001
+ Text('RAM Warning'),
1002
+ ],
1003
+ ),
1004
+ content: Text(
1005
+ 'Your device has ${deviceMemory!.availableMemoryFormatted} available RAM, but this model recommends at least ${model.minRamMb ~/ 1024 + 1} GB. Loading it might fail or cause instability.',
1006
+ ),
1007
+ actions: [
1008
+ TextButton(
1009
+ onPressed: () => Navigator.pop(ctx, false),
1010
+ child: const Text('Cancel'),
1011
+ ),
1012
+ TextButton(
1013
+ onPressed: () => Navigator.pop(ctx, true),
1014
+ style: TextButton.styleFrom(foregroundColor: Colors.orange),
1015
+ child: const Text('Proceed Anyway'),
1016
+ ),
1017
+ ],
1018
+ ),
1019
+ );
1020
+ return result ?? false;
1021
+ }
1022
+
1023
void _selectModel(BuildContext context, WidgetRef ref) {
1024
final modelInfo = ModelInfo(
1025
id: model.id,
lib/features/on_device/views/model_manager_screen.dart
+360
-5
@@ -12,6 +12,8 @@ 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
+import '../../../core/providers/device_info_providers.dart';
16
+import '../../../core/device/device_memory_service.dart';
17
18
class OnDeviceModelManagerScreen extends ConsumerWidget {
19
const OnDeviceModelManagerScreen({super.key});
@@ -24,6 +26,7 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
26
final engineState = ref.watch(onDeviceEngineProvider);
27
final downloadProgress = ref.watch(foregroundDownloadNotifierProvider);
28
final isAndroid = Platform.isAndroid;
29
+ final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
30
31
return Scaffold(
32
drawer: const SidebarWidget(),
@@ -53,6 +56,7 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
56
],
57
),
58
),
59
+ _buildMemoryInfo(context, ref, deviceMemoryAsync),
60
if (engineState.status == EngineStatus.loaded)
61
Container(
62
padding: const EdgeInsets.all(12),
@@ -108,6 +112,7 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
112
downloadedAsync: downloadedAsync,
113
downloadProgress: downloadProgress[model.id],
114
engineState: engineState,
115
+ deviceMemory: deviceMemoryAsync.value,
116
),
117
),
118
const SizedBox(height: 24),
@@ -115,6 +120,280 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
120
),
121
);
122
}
123
+
124
+ Widget _buildMemoryInfo(
125
+ BuildContext context,
126
+ WidgetRef ref,
127
+ AsyncValue<DeviceMemoryInfo> memoryAsync,
128
+ ) {
129
+ return memoryAsync.when(
130
+ data: (info) {
131
+ if (info.totalMemoryMb == 0) return const SizedBox.shrink();
132
+ final theme = Theme.of(context);
133
+ final usedMb = info.totalMemoryMb - info.availableMemoryMb;
134
+ final usagePercent = usedMb / info.totalMemoryMb;
135
+
136
+ // Memory health logic
137
+ Color statusColor = Colors.green;
138
+ String statusText = 'Healthy';
139
+ if (info.availableMemoryMb < 1024) {
140
+ statusColor = Colors.red;
141
+ statusText = 'Critical';
142
+ } else if (info.availableMemoryMb < 2048) {
143
+ statusColor = Colors.orange;
144
+ statusText = 'Low';
145
+ }
146
+
147
+ return Container(
148
+ margin: const EdgeInsets.only(bottom: 24),
149
+ decoration: BoxDecoration(
150
+ borderRadius: BorderRadius.circular(20),
151
+ gradient: LinearGradient(
152
+ begin: Alignment.topLeft,
153
+ end: Alignment.bottomRight,
154
+ colors: [
155
+ theme.colorScheme.surfaceContainerHighest,
156
+ theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
157
+ ],
158
+ ),
159
+ boxShadow: [
160
+ BoxShadow(
161
+ color: Colors.black.withValues(alpha: 0.05),
162
+ blurRadius: 10,
163
+ offset: const Offset(0, 4),
164
+ ),
165
+ ],
166
+ border: Border.all(
167
+ color: theme.colorScheme.outline.withValues(alpha: 0.1),
168
+ ),
169
+ ),
170
+ child: Column(
171
+ children: [
172
+ Padding(
173
+ padding: const EdgeInsets.fromLTRB(20, 16, 12, 8),
174
+ child: Row(
175
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
176
+ children: [
177
+ Row(
178
+ children: [
179
+ Icon(
180
+ Icons.memory_rounded,
181
+ size: 20,
182
+ color: theme.colorScheme.primary,
183
+ ),
184
+ const SizedBox(width: 8),
185
+ Text(
186
+ 'Device Memory',
187
+ style: theme.textTheme.titleMedium?.copyWith(
188
+ fontWeight: FontWeight.bold,
189
+ letterSpacing: -0.5,
190
+ ),
191
+ ),
192
+ ],
193
+ ),
194
+ ShadButton.ghost(
195
+ size: ShadButtonSize.sm,
196
+ onPressed: () => ref.invalidate(deviceMemoryProvider),
197
+ child: const Icon(Icons.refresh_rounded, size: 18),
198
+ ),
199
+ ],
200
+ ),
201
+ ),
202
+ Padding(
203
+ padding: const EdgeInsets.symmetric(horizontal: 20),
204
+ child: Column(
205
+ children: [
206
+ Row(
207
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
208
+ children: [
209
+ Text(
210
+ 'RAM Usage',
211
+ style: theme.textTheme.labelMedium?.copyWith(
212
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
213
+ ),
214
+ ),
215
+ Container(
216
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
217
+ decoration: BoxDecoration(
218
+ color: statusColor.withValues(alpha: 0.1),
219
+ borderRadius: BorderRadius.circular(12),
220
+ border: Border.all(color: statusColor.withValues(alpha: 0.2)),
221
+ ),
222
+ child: Text(
223
+ statusText,
224
+ style: theme.textTheme.labelSmall?.copyWith(
225
+ color: statusColor,
226
+ fontWeight: FontWeight.bold,
227
+ ),
228
+ ),
229
+ ),
230
+ ],
231
+ ),
232
+ const SizedBox(height: 12),
233
+ ClipRRect(
234
+ borderRadius: BorderRadius.circular(10),
235
+ child: Stack(
236
+ children: [
237
+ Container(
238
+ height: 10,
239
+ width: double.infinity,
240
+ color: theme.colorScheme.surfaceContainerLowest,
241
+ ),
242
+ AnimatedContainer(
243
+ duration: const Duration(milliseconds: 500),
244
+ height: 10,
245
+ width: MediaQuery.of(context).size.width * usagePercent,
246
+ decoration: BoxDecoration(
247
+ gradient: LinearGradient(
248
+ colors: [
249
+ statusColor,
250
+ statusColor.withValues(alpha: 0.7),
251
+ ],
252
+ ),
253
+ borderRadius: BorderRadius.circular(10),
254
+ boxShadow: [
255
+ BoxShadow(
256
+ color: statusColor.withValues(alpha: 0.3),
257
+ blurRadius: 4,
258
+ offset: const Offset(0, 2),
259
+ ),
260
+ ],
261
+ ),
262
+ ),
263
+ ],
264
+ ),
265
+ ),
266
+ const SizedBox(height: 8),
267
+ Row(
268
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
269
+ children: [
270
+ Text(
271
+ '${(usagePercent * 100).toStringAsFixed(0)}% used',
272
+ style: theme.textTheme.labelSmall?.copyWith(
273
+ fontWeight: FontWeight.w600,
274
+ ),
275
+ ),
276
+ Text(
277
+ '${_formatMb(usedMb)} / ${info.totalMemoryFormatted}',
278
+ style: theme.textTheme.labelSmall?.copyWith(
279
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
280
+ ),
281
+ ),
282
+ ],
283
+ ),
284
+ ],
285
+ ),
286
+ ),
287
+ const SizedBox(height: 16),
288
+ const Divider(height: 1),
289
+ Padding(
290
+ padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
291
+ child: Row(
292
+ children: [
293
+ Expanded(
294
+ child: _MemoryStat(
295
+ label: 'Available RAM',
296
+ value: info.availableMemoryFormatted,
297
+ icon: Icons.speed_rounded,
298
+ color: statusColor,
299
+ isPrimary: true,
300
+ ),
301
+ ),
302
+ Container(
303
+ height: 30,
304
+ width: 1,
305
+ color: theme.colorScheme.outline.withValues(alpha: 0.1),
306
+ ),
307
+ Expanded(
308
+ child: _MemoryStat(
309
+ label: 'Total Capacity',
310
+ value: info.totalMemoryFormatted,
311
+ icon: Icons.storage_rounded,
312
+ alignment: CrossAxisAlignment.end,
313
+ ),
314
+ ),
315
+ ],
316
+ ),
317
+ ),
318
+ ],
319
+ ),
320
+ );
321
+ },
322
+ loading: () => const Center(
323
+ child: Padding(
324
+ padding: EdgeInsets.all(24.0),
325
+ child: CircularProgressIndicator(),
326
+ ),
327
+ ),
328
+ error: (_, _) => const SizedBox.shrink(),
329
+ );
330
+ }
331
+
332
+ String _formatMb(int mb) {
333
+ if (mb >= 1024) {
334
+ return '${(mb / 1024).toStringAsFixed(1)} GB';
335
+ }
336
+ return '$mb MB';
337
+ }
338
+}
339
+
340
+class _MemoryStat extends StatelessWidget {
341
+ final String label;
342
+ final String value;
343
+ final IconData icon;
344
+ final Color? color;
345
+ final CrossAxisAlignment alignment;
346
+ final bool isPrimary;
347
+
348
+ const _MemoryStat({
349
+ required this.label,
350
+ required this.value,
351
+ required this.icon,
352
+ this.color,
353
+ this.alignment = CrossAxisAlignment.start,
354
+ this.isPrimary = false,
355
+ });
356
+
357
+ @override
358
+ Widget build(BuildContext context) {
359
+ final theme = Theme.of(context);
360
+ final iconColor = color ?? theme.colorScheme.onSurface.withValues(alpha: 0.4);
361
+
362
+ return Column(
363
+ crossAxisAlignment: alignment,
364
+ children: [
365
+ Row(
366
+ mainAxisSize: MainAxisSize.min,
367
+ children: [
368
+ if (alignment == CrossAxisAlignment.start) ...[
369
+ Icon(icon, size: 14, color: iconColor),
370
+ const SizedBox(width: 6),
371
+ ],
372
+ Text(
373
+ label,
374
+ style: theme.textTheme.labelSmall?.copyWith(
375
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
376
+ fontSize: 10,
377
+ ),
378
+ ),
379
+ if (alignment == CrossAxisAlignment.end) ...[
380
+ const SizedBox(width: 6),
381
+ Icon(icon, size: 14, color: iconColor),
382
+ ],
383
+ ],
384
+ ),
385
+ const SizedBox(height: 2),
386
+ Text(
387
+ value,
388
+ style: (isPrimary ? theme.textTheme.titleMedium : theme.textTheme.bodyMedium)?.copyWith(
389
+ fontWeight: FontWeight.bold,
390
+ color: color,
391
+ letterSpacing: isPrimary ? -0.5 : null,
392
+ ),
393
+ ),
394
+ ],
395
+ );
396
+ }
397
}
398
399
class _ModelCard extends ConsumerWidget {
@@ -124,6 +403,7 @@ class _ModelCard extends ConsumerWidget {
403
required this.downloadedAsync,
404
required this.downloadProgress,
405
required this.engineState,
406
+ this.deviceMemory,
407
});
408
409
final OnDeviceModel model;
@@ -131,6 +411,7 @@ class _ModelCard extends ConsumerWidget {
411
final AsyncValue<Set<String>> downloadedAsync;
412
final DownloadProgressInfo? downloadProgress;
413
final OnDeviceEngineState engineState;
414
+ final DeviceMemoryInfo? deviceMemory;
415
416
@override
417
Widget build(BuildContext context, WidgetRef ref) {
@@ -200,6 +481,32 @@ class _ModelCard extends ConsumerWidget {
481
),
482
],
483
),
484
+ if (deviceMemory != null && deviceMemory!.isOversized(model.minRamMb))
485
+ Padding(
486
+ padding: const EdgeInsets.only(top: 6),
487
+ child: Container(
488
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
489
+ decoration: BoxDecoration(
490
+ color: Colors.orange.withValues(alpha: 0.1),
491
+ borderRadius: BorderRadius.circular(6),
492
+ border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
493
+ ),
494
+ child: Row(
495
+ mainAxisSize: MainAxisSize.min,
496
+ children: [
497
+ const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 14),
498
+ const SizedBox(width: 6),
499
+ Text(
500
+ 'May be too large for this device',
501
+ style: theme.textTheme.labelSmall?.copyWith(
502
+ color: Colors.orange,
503
+ fontWeight: FontWeight.bold,
504
+ ),
505
+ ),
506
+ ],
507
+ ),
508
+ ),
509
+ ),
510
const SizedBox(height: 4),
511
Text(
512
model.description,
@@ -282,7 +589,7 @@ class _ModelCard extends ConsumerWidget {
589
else
590
ShadButton.outline(
591
size: ShadButtonSize.sm,
285
- onPressed: () => _loadModel(ref),
592
+ onPressed: () => _loadModel(context, ref),
593
child: const Text('Load'),
594
),
595
const SizedBox(width: 8),
@@ -381,7 +688,7 @@ class _ModelCard extends ConsumerWidget {
688
const Spacer(),
689
ShadButton.outline(
690
size: ShadButtonSize.sm,
384
- onPressed: () => _startDownload(ref),
691
+ onPressed: () => _startDownload(context, ref),
692
child: const Text('Resume'),
693
),
694
],
@@ -393,25 +700,73 @@ class _ModelCard extends ConsumerWidget {
700
const Spacer(),
701
ShadButton.outline(
702
size: ShadButtonSize.sm,
396
- onPressed: () => _startDownload(ref),
703
+ onPressed: () => _startDownload(context, ref),
704
child: const Text('Download'),
705
),
706
],
707
);
708
}
709
403
- Future<void> _startDownload(WidgetRef ref) async {
710
+ Future<void> _startDownload(BuildContext context, WidgetRef ref) async {
711
+ if (deviceMemory != null) {
712
+ if (deviceMemory!.isOversized(model.minRamMb)) {
713
+ final proceed = await _showRamWarning(
714
+ context,
715
+ 'This model requires at least ${model.minRamMb ~/ 1024 + 1} GB RAM, but your device has ${deviceMemory!.totalMemoryFormatted}. It may not run correctly or could cause the app to crash.',
716
+ );
717
+ if (!proceed) return;
718
+ }
719
+ }
720
+
721
await ref
722
.read(foregroundDownloadNotifierProvider.notifier)
723
.startDownload(model.id);
724
}
725
409
- Future<void> _loadModel(WidgetRef ref) async {
726
+ Future<void> _loadModel(BuildContext context, WidgetRef ref) async {
727
+ if (deviceMemory != null) {
728
+ if (!deviceMemory!.hasEnoughRam(model.minRamMb)) {
729
+ final proceed = await _showRamWarning(
730
+ context,
731
+ 'Your device has ${deviceMemory!.availableMemoryFormatted} available RAM, but this model recommends at least ${model.minRamMb ~/ 1024 + 1} GB. Loading it might fail or cause instability.',
732
+ );
733
+ if (!proceed) return;
734
+ }
735
+ }
736
+
737
final settings = ref.read(settingsProvider);
738
final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
739
await engineNotifier.loadModel(model.id, settings.preferredBackend);
740
}
741
742
+ Future<bool> _showRamWarning(BuildContext context, String message) async {
743
+ final result = await showDialog<bool>(
744
+ context: context,
745
+ builder: (ctx) => AlertDialog(
746
+ title: const Row(
747
+ children: [
748
+ Icon(Icons.warning_amber_rounded, color: Colors.orange),
749
+ SizedBox(width: 8),
750
+ Text('RAM Warning'),
751
+ ],
752
+ ),
753
+ content: Text(message),
754
+ actions: [
755
+ TextButton(
756
+ onPressed: () => Navigator.pop(ctx, false),
757
+ child: const Text('Cancel'),
758
+ ),
759
+ TextButton(
760
+ onPressed: () => Navigator.pop(ctx, true),
761
+ style: TextButton.styleFrom(foregroundColor: Colors.orange),
762
+ child: const Text('Proceed Anyway'),
763
+ ),
764
+ ],
765
+ ),
766
+ );
767
+ return result ?? false;
768
+ }
769
+
770
Future<void> _unloadModel(WidgetRef ref) async {
771
await ref.read(onDeviceEngineProvider.notifier).unloadModel();
772
}
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+152
-6
@@ -15,6 +15,8 @@ import '../../on_device/providers/on_device_providers.dart';
15
import '../../on_device/providers/foreground_download_providers.dart';
16
import '../../servers/data/models/server.dart';
17
import '../../servers/providers/server_providers.dart';
18
+import '../../../core/providers/device_info_providers.dart';
19
+import '../../../core/device/device_memory_service.dart';
20
21
class OnboardingModelDownloadScreen extends ConsumerStatefulWidget {
22
const OnboardingModelDownloadScreen({super.key});
@@ -35,6 +37,7 @@ class _OnboardingModelDownloadScreenState
37
final downloadedModelsAsync = ref.watch(downloadedModelsProvider);
38
final downloadStates = ref.watch(foregroundDownloadNotifierProvider);
39
final isAndroid = Platform.isAndroid;
40
+ final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
41
42
return Scaffold(
43
appBar: AppBar(title: const Text('Download a Model')),
@@ -71,6 +74,7 @@ class _OnboardingModelDownloadScreenState
74
],
75
),
76
),
77
+ _buildMemoryInfo(context, ref, deviceMemoryAsync),
78
...models.map((model) {
79
final isDownloaded = downloadedModelsAsync.when(
80
data: (set) => set.contains(model.id),
@@ -86,6 +90,7 @@ class _OnboardingModelDownloadScreenState
90
isDownloaded: isDownloaded,
91
progressInfo: progressInfo,
92
theme: theme,
93
+ deviceMemory: deviceMemoryAsync.value,
94
),
95
);
96
}),
@@ -148,6 +153,89 @@ class _OnboardingModelDownloadScreenState
153
}
154
}
155
}
156
+
157
+ Widget _buildMemoryInfo(
158
+ BuildContext context,
159
+ WidgetRef ref,
160
+ AsyncValue<DeviceMemoryInfo> memoryAsync,
161
+ ) {
162
+ return memoryAsync.when(
163
+ data: (info) {
164
+ if (info.totalMemoryMb == 0) return const SizedBox.shrink();
165
+ final theme = Theme.of(context);
166
+ return Container(
167
+ padding: const EdgeInsets.all(12),
168
+ margin: const EdgeInsets.only(bottom: 24),
169
+ decoration: BoxDecoration(
170
+ color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
171
+ borderRadius: BorderRadius.circular(12),
172
+ border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.1)),
173
+ ),
174
+ child: Row(
175
+ children: [
176
+ _MemoryStat(
177
+ label: 'Total RAM',
178
+ value: info.totalMemoryFormatted,
179
+ icon: Icons.memory,
180
+ ),
181
+ const SizedBox(width: 24),
182
+ _MemoryStat(
183
+ label: 'Available',
184
+ value: info.availableMemoryFormatted,
185
+ icon: Icons.event_available,
186
+ color: info.availableMemoryMb < 1024 ? Colors.orange : null,
187
+ ),
188
+ ],
189
+ ),
190
+ );
191
+ },
192
+ loading: () => const SizedBox.shrink(),
193
+ error: (_, _) => const SizedBox.shrink(),
194
+ );
195
+ }
196
+}
197
+
198
+class _MemoryStat extends StatelessWidget {
199
+ final String label;
200
+ final String value;
201
+ final IconData icon;
202
+ final Color? color;
203
+
204
+ const _MemoryStat({
205
+ required this.label,
206
+ required this.value,
207
+ required this.icon,
208
+ this.color,
209
+ });
210
+
211
+ @override
212
+ Widget build(BuildContext context) {
213
+ final theme = Theme.of(context);
214
+ return Row(
215
+ children: [
216
+ Icon(icon, size: 16, color: color ?? theme.colorScheme.primary),
217
+ const SizedBox(width: 8),
218
+ Column(
219
+ crossAxisAlignment: CrossAxisAlignment.start,
220
+ children: [
221
+ Text(
222
+ label,
223
+ style: theme.textTheme.labelSmall?.copyWith(
224
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
225
+ ),
226
+ ),
227
+ Text(
228
+ value,
229
+ style: theme.textTheme.bodyMedium?.copyWith(
230
+ fontWeight: FontWeight.bold,
231
+ color: color,
232
+ ),
233
+ ),
234
+ ],
235
+ ),
236
+ ],
237
+ );
238
+ }
239
}
240
241
class _ModelCard extends ConsumerWidget {
@@ -155,12 +243,14 @@ class _ModelCard extends ConsumerWidget {
243
final bool isDownloaded;
244
final DownloadProgressInfo? progressInfo;
245
final ThemeData theme;
246
+ final DeviceMemoryInfo? deviceMemory;
247
248
const _ModelCard({
249
required this.model,
250
required this.isDownloaded,
251
this.progressInfo,
252
required this.theme,
253
+ this.deviceMemory,
254
});
255
256
@override
@@ -217,6 +307,23 @@ class _ModelCard extends ConsumerWidget {
307
),
308
],
309
),
310
+ if (deviceMemory != null && deviceMemory!.isOversized(model.minRamMb))
311
+ Padding(
312
+ padding: const EdgeInsets.only(top: 4),
313
+ child: Row(
314
+ children: [
315
+ const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 14),
316
+ const SizedBox(width: 4),
317
+ Text(
318
+ 'May be too large for this device',
319
+ style: theme.textTheme.labelSmall?.copyWith(
320
+ color: Colors.orange,
321
+ fontWeight: FontWeight.bold,
322
+ ),
323
+ ),
324
+ ],
325
+ ),
326
+ ),
327
const SizedBox(height: 4),
328
Text(
329
model.description,
@@ -299,9 +406,7 @@ class _ModelCard extends ConsumerWidget {
406
const Spacer(),
407
ShadButton.outline(
408
size: ShadButtonSize.sm,
302
- onPressed: () => ref
303
- .read(foregroundDownloadNotifierProvider.notifier)
304
- .startDownload(model.id),
409
+ onPressed: () => _startDownload(context, ref),
410
child: const Text('Resume'),
411
),
412
],
@@ -309,13 +414,54 @@ class _ModelCard extends ConsumerWidget {
414
else
415
ShadButton.outline(
416
size: ShadButtonSize.sm,
312
- onPressed: () => ref
313
- .read(foregroundDownloadNotifierProvider.notifier)
314
- .startDownload(model.id),
417
+ onPressed: () => _startDownload(context, ref),
418
child: const Text('Download'),
419
),
420
],
421
),
422
);
423
}
424
+
425
+ Future<void> _startDownload(BuildContext context, WidgetRef ref) async {
426
+ if (deviceMemory != null) {
427
+ if (deviceMemory!.isOversized(model.minRamMb)) {
428
+ final proceed = await _showRamWarning(context);
429
+ if (!proceed) return;
430
+ }
431
+ }
432
+
433
+ await ref
434
+ .read(foregroundDownloadNotifierProvider.notifier)
435
+ .startDownload(model.id);
436
+ }
437
+
438
+ Future<bool> _showRamWarning(BuildContext context) async {
439
+ final result = await showDialog<bool>(
440
+ context: context,
441
+ builder: (ctx) => AlertDialog(
442
+ title: const Row(
443
+ children: [
444
+ Icon(Icons.warning_amber_rounded, color: Colors.orange),
445
+ SizedBox(width: 8),
446
+ Text('RAM Warning'),
447
+ ],
448
+ ),
449
+ content: Text(
450
+ 'This model requires at least ${model.minRamMb ~/ 1024 + 1} GB RAM, but your device has ${deviceMemory!.totalMemoryFormatted}. It may not run correctly or could cause the app to crash.',
451
+ ),
452
+ actions: [
453
+ TextButton(
454
+ onPressed: () => Navigator.pop(ctx, false),
455
+ child: const Text('Cancel'),
456
+ ),
457
+ TextButton(
458
+ onPressed: () => Navigator.pop(ctx, true),
459
+ style: TextButton.styleFrom(foregroundColor: Colors.orange),
460
+ child: const Text('Proceed Anyway'),
461
+ ),
462
+ ],
463
+ ),
464
+ );
465
+ return result ?? false;
466
+ }
467
}
lib/features/settings/screens/settings_screen.dart
+27
-25
@@ -1,6 +1,7 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:go_router/go_router.dart';
4
+import 'package:localmind/features/sidebar/sidebar_widget.dart';
5
import '../../../core/providers/app_providers.dart';
6
import '../../../core/models/enums.dart';
7
import '../../../core/routes/app_routes.dart';
@@ -21,13 +22,8 @@ class SettingsScreen extends ConsumerWidget {
22
final isDark = theme.brightness == Brightness.dark;
23
24
return Scaffold(
24
- appBar: AppBar(
25
- title: const Text('Settings'),
26
- leading: IconButton(
27
- icon: const Icon(Icons.arrow_back),
28
- onPressed: () => context.pop(),
29
- ),
30
- ),
25
+ appBar: AppBar(title: const Text('Settings')),
26
+ drawer: SidebarWidget(),
27
body: ListView(
28
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
29
children: [
@@ -62,7 +58,11 @@ class SettingsScreen extends ConsumerWidget {
58
),
59
const Divider(height: 32),
60
_SectionHeader(title: 'Text-to-Speech'),
65
- _TtsEngineToggle(current: settings.ttsEngine, ref: ref, isDark: isDark),
61
+ _TtsEngineToggle(
62
+ current: settings.ttsEngine,
63
+ ref: ref,
64
+ isDark: isDark,
65
+ ),
66
if (settings.ttsEngine == TtsEngine.kitten) ...[
67
const SizedBox(height: 8),
68
_KittenVoiceDropdown(
@@ -918,15 +918,15 @@ class _TtsEngineToggle extends StatelessWidget {
918
color: current == TtsEngine.system
919
? accent.withValues(alpha: 0.15)
920
: (isDark
921
- ? const Color(0xFF1F1F1F)
922
- : const Color(0xFFF5F5F5)),
921
+ ? const Color(0xFF1F1F1F)
922
+ : const Color(0xFFF5F5F5)),
923
borderRadius: BorderRadius.circular(8),
924
border: Border.all(
925
color: current == TtsEngine.system
926
? accent
927
: (isDark
928
- ? const Color(0xFF3A3A3A)
929
- : const Color(0xFFE5E5E5)),
928
+ ? const Color(0xFF3A3A3A)
929
+ : const Color(0xFFE5E5E5)),
930
),
931
),
932
child: Column(
@@ -937,8 +937,8 @@ class _TtsEngineToggle extends StatelessWidget {
937
color: current == TtsEngine.system
938
? accent
939
: (isDark
940
- ? const Color(0xFF888888)
941
- : const Color(0xFF999999)),
940
+ ? const Color(0xFF888888)
941
+ : const Color(0xFF999999)),
942
),
943
const SizedBox(height: 4),
944
Text(
@@ -951,8 +951,8 @@ class _TtsEngineToggle extends StatelessWidget {
951
color: current == TtsEngine.system
952
? accent
953
: (isDark
954
- ? const Color(0xFF888888)
955
- : const Color(0xFF999999)),
954
+ ? const Color(0xFF888888)
955
+ : const Color(0xFF999999)),
956
),
957
),
958
],
@@ -972,15 +972,15 @@ class _TtsEngineToggle extends StatelessWidget {
972
color: current == TtsEngine.kitten
973
? accent.withValues(alpha: 0.15)
974
: (isDark
975
- ? const Color(0xFF1F1F1F)
976
- : const Color(0xFFF5F5F5)),
975
+ ? const Color(0xFF1F1F1F)
976
+ : const Color(0xFFF5F5F5)),
977
borderRadius: BorderRadius.circular(8),
978
border: Border.all(
979
color: current == TtsEngine.kitten
980
? accent
981
: (isDark
982
- ? const Color(0xFF3A3A3A)
983
- : const Color(0xFFE5E5E5)),
982
+ ? const Color(0xFF3A3A3A)
983
+ : const Color(0xFFE5E5E5)),
984
),
985
),
986
child: Column(
@@ -991,8 +991,8 @@ class _TtsEngineToggle extends StatelessWidget {
991
color: current == TtsEngine.kitten
992
? accent
993
: (isDark
994
- ? const Color(0xFF888888)
995
- : const Color(0xFF999999)),
994
+ ? const Color(0xFF888888)
995
+ : const Color(0xFF999999)),
996
),
997
const SizedBox(height: 4),
998
Text(
@@ -1005,8 +1005,8 @@ class _TtsEngineToggle extends StatelessWidget {
1005
color: current == TtsEngine.kitten
1006
? accent
1007
: (isDark
1008
- ? const Color(0xFF888888)
1009
- : const Color(0xFF999999)),
1008
+ ? const Color(0xFF888888)
1009
+ : const Color(0xFF999999)),
1010
),
1011
),
1012
],
@@ -1064,7 +1064,9 @@ class _KittenVoiceDropdown extends StatelessWidget {
1064
color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5),
1065
borderRadius: BorderRadius.circular(8),
1066
border: Border.all(
1067
- color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE5E5E5),
1067
+ color: isDark
1068
+ ? const Color(0xFF3A3A3A)
1069
+ : const Color(0xFFE5E5E5),
1070
),
1071
),
1072
child: DropdownButtonHideUnderline(
lib/main.dart
+1
-1
@@ -30,7 +30,7 @@ Future<void> main() async {
30
// Pre-initialize KittenTTS (neural engine)
31
// Don't await it to avoid blocking app startup, but start the process
32
container.read(kittenTtsServiceProvider).initialize().catchError((e) {
33
- print('Failed to pre-initialize KittenTTS: $e');
33
+ debugPrint('Failed to pre-initialize KittenTTS: $e');
34
});
35
36
runApp(
pubspec.lock
+24
@@ -297,6 +297,22 @@ packages:
297
url: "https://pub.dev"
298
source: hosted
299
version: "0.7.12"
300
+ device_info_plus:
301
+ dependency: "direct main"
302
+ description:
303
+ name: device_info_plus
304
+ sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
305
+ url: "https://pub.dev"
306
+ source: hosted
307
+ version: "12.4.0"
308
+ device_info_plus_platform_interface:
309
+ dependency: transitive
310
+ description:
311
+ name: device_info_plus_platform_interface
312
+ sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
313
+ url: "https://pub.dev"
314
+ source: hosted
315
+ version: "7.0.3"
316
dio:
317
dependency: "direct main"
318
description:
@@ -1451,6 +1467,14 @@ packages:
1467
url: "https://pub.dev"
1468
source: hosted
1469
version: "5.15.0"
1470
+ win32_registry:
1471
+ dependency: transitive
1472
+ description:
1473
+ name: win32_registry
1474
+ sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
1475
+ url: "https://pub.dev"
1476
+ source: hosted
1477
+ version: "2.1.0"
1478
xdg_directories:
1479
dependency: transitive
1480
description:
pubspec.yaml
+1
@@ -61,6 +61,7 @@ dependencies:
61
flutter_local_notifications: ^18.0.1
62
kitten_tts_flutter: ^0.0.1
63
audioplayers: ^6.4.0
64
+ device_info_plus: ^12.4.0
65
66
dev_dependencies:
67
flutter_test: