@tduncan / Cortana / commits / a2d179c

feat: implement device memory monitoring and add warnings for resource-intensive models

abdulmominsakib committed Apr 28, 2026 at 07:10 UTC a2d179cef6f748e8bb155c07e0191e7f53b1e4d4
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: