11 files changed
+370
-21
README.md
-9
@@ -16,15 +16,6 @@ LocalMind is a premium Flutter mobile application designed to provide a beautifu
16
</a>
17
</p>
18
19
-
20
-## 🆕 What's New (v1.1.0)
21
-
22
-- 📲 **On-Device AI Support (Android)**: Private, fast, and offline. You can now run powerful AI models directly on your device. No internet required for conversations!
23
-- 📂 **All-New Model Manager**: Download and manage curated AI models like **Qwen 3 (0.6B)**, **DeepSeek R1**, and **Gemma 4** directly within the app.
24
-- 🎨 **Sidebar Revamped**: A more intuitive and feature-rich navigation experience.
25
-- ⚡ **Refactored Asset Management**: Improved efficiency and smaller app footprint for better performance.
26
-- 🐞 **General Bug Fixes**: Stability improvements and minor UI refinements.
27
-
19
## 🚀 Vision
20
21
Built for those who value privacy and performance, LocalMind connects directly to your local inference servers like **LM Studio** or **Ollama**, as well as cloud providers like **OpenRouter**. No middleware, no tracking, and no hidden subscriptions. Just you and your models.
android/app/src/main/AndroidManifest.xml
+2
-2
@@ -2,7 +2,7 @@
2
xmlns:tools="http://schemas.android.com/tools">
3
<uses-permission android:name="android.permission.INTERNET" />
4
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
5
- <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
5
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SHORT_SERVICE" />
6
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7
<uses-permission android:name="android.permission.WAKE_LOCK" />
8
@@ -43,7 +43,7 @@
43
android:name=".ChatForegroundService"
44
android:enabled="true"
45
android:exported="false"
46
- android:foregroundServiceType="dataSync" />
46
+ android:foregroundServiceType="shortService" />
47
</application>
48
<!-- Required to query activities that can process text, see:
49
https://developer.android.com/training/package-visibility and
lib/app.dart
+6
@@ -11,6 +11,7 @@ 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_notification_permission_screen.dart';
15
import 'features/onboarding/screens/onboarding_server_setup_screen.dart';
16
import 'features/onboarding/screens/onboarding_server_type_screen.dart';
17
import 'features/onboarding/screens/onboarding_theme_screen.dart';
@@ -76,6 +77,11 @@ final routerProvider = Provider<GoRouter>((ref) {
77
pageBuilder: (context, state) =>
78
const MaterialPage(child: OnboardingThemeScreen()),
79
),
80
+ GoRoute(
81
+ path: AppRoutes.onboardingNotifications,
82
+ pageBuilder: (context, state) =>
83
+ const MaterialPage(child: OnboardingNotificationPermissionScreen()),
84
+ ),
85
ShellRoute(
86
navigatorKey: _shellNavigatorKey,
87
builder: (context, state, child) {
lib/core/routes/app_routes.dart
+1
@@ -4,6 +4,7 @@ class AppRoutes {
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 onboardingNotifications = '/onboarding/notifications';
8
static const String servers = '/servers';
9
static const String addServer = '/servers/add';
10
static const String personas = '/personas';
lib/features/chat/views/chat_screen.dart
+2
@@ -15,6 +15,7 @@ import '../providers/chat_providers.dart';
15
import 'components/chat_bubble.dart';
16
import 'components/chat_input_bar.dart';
17
import 'components/mcp_config_sheet.dart';
18
+import 'components/notification_permission_banner.dart';
19
import '../../conversations/data/models/conversation.dart';
20
import 'package:localmind/features/conversations/providers/conversation_providers.dart'
21
as conv;
@@ -194,6 +195,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
195
],
196
),
197
),
198
+ const NotificationPermissionBanner(),
199
if (connectionStatus == ConnectionStatus.disconnected ||
200
connectionStatus == ConnectionStatus.error)
201
_ConnectionBanner(status: connectionStatus),
lib/features/chat/views/components/notification_permission_banner.dart
new
+125
@@ -0,0 +1,125 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:hugeicons/hugeicons.dart';
4
+import 'package:shadcn_ui/shadcn_ui.dart';
5
+
6
+import '../../../../core/providers/app_providers.dart';
7
+import '../../../on_device/providers/on_device_providers.dart';
8
+
9
+class NotificationPermissionBanner extends ConsumerStatefulWidget {
10
+ const NotificationPermissionBanner({super.key});
11
+
12
+ @override
13
+ ConsumerState<NotificationPermissionBanner> createState() =>
14
+ _NotificationPermissionBannerState();
15
+}
16
+
17
+class _NotificationPermissionBannerState
18
+ extends ConsumerState<NotificationPermissionBanner> {
19
+ bool _isVisible = false;
20
+
21
+ @override
22
+ void initState() {
23
+ super.initState();
24
+ _checkStatus();
25
+ }
26
+
27
+ Future<void> _checkStatus() async {
28
+ final settings = ref.read(settingsProvider);
29
+ if (settings.hasAskedForNotifications) return;
30
+
31
+ final service = ref.read(downloadNotificationServiceProvider);
32
+ final granted = await service.isPermissionGranted();
33
+
34
+ if (!granted) {
35
+ setState(() => _isVisible = true);
36
+ } else {
37
+ // If already granted somehow, mark as asked
38
+ await ref
39
+ .read(settingsProvider.notifier)
40
+ .updateSettings(settings.copyWith(hasAskedForNotifications: true));
41
+ }
42
+ }
43
+
44
+ Future<void> _requestPermission() async {
45
+ final service = ref.read(downloadNotificationServiceProvider);
46
+ await service.requestPermission();
47
+ await _dismiss(true);
48
+ }
49
+
50
+ Future<void> _dismiss(bool asked) async {
51
+ final settings = ref.read(settingsProvider);
52
+ await ref
53
+ .read(settingsProvider.notifier)
54
+ .updateSettings(settings.copyWith(hasAskedForNotifications: true));
55
+ setState(() => _isVisible = false);
56
+ }
57
+
58
+ @override
59
+ Widget build(BuildContext context) {
60
+ if (!_isVisible) return const SizedBox.shrink();
61
+
62
+ final theme = Theme.of(context);
63
+
64
+ return Container(
65
+ width: double.infinity,
66
+ padding: const EdgeInsets.all(16),
67
+ decoration: BoxDecoration(
68
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
69
+ border: Border(
70
+ bottom: BorderSide(
71
+ color: theme.colorScheme.primary.withValues(alpha: 0.2),
72
+ ),
73
+ ),
74
+ ),
75
+ child: Row(
76
+ children: [
77
+ Container(
78
+ padding: const EdgeInsets.all(8),
79
+ decoration: BoxDecoration(
80
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
81
+ shape: BoxShape.circle,
82
+ ),
83
+ child: HugeIcon(
84
+ icon: HugeIcons.strokeRoundedNotification03,
85
+ size: 20,
86
+ color: theme.colorScheme.primary,
87
+ ),
88
+ ),
89
+ const SizedBox(width: 12),
90
+ Expanded(
91
+ child: Column(
92
+ crossAxisAlignment: CrossAxisAlignment.start,
93
+ children: [
94
+ Text(
95
+ 'Enable Notifications',
96
+ style: theme.textTheme.labelLarge?.copyWith(
97
+ fontWeight: FontWeight.bold,
98
+ ),
99
+ ),
100
+ Text(
101
+ 'Get notified when models finish downloading.',
102
+ style: theme.textTheme.bodySmall?.copyWith(
103
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
104
+ ),
105
+ ),
106
+ ],
107
+ ),
108
+ ),
109
+ const SizedBox(width: 8),
110
+ ShadButton.outline(
111
+ size: ShadButtonSize.sm,
112
+ onPressed: () => _dismiss(true),
113
+ child: const Text('Not Now'),
114
+ ),
115
+ const SizedBox(width: 8),
116
+ ShadButton(
117
+ size: ShadButtonSize.sm,
118
+ onPressed: _requestPermission,
119
+ child: const Text('Enable'),
120
+ ),
121
+ ],
122
+ ),
123
+ );
124
+ }
125
+}
lib/features/on_device/data/download_notification_service.dart
+42
@@ -122,4 +122,46 @@ class DownloadNotificationService {
122
Future<void> cancelNotification(int id) async {
123
await _notifications.cancel(id);
124
}
125
+
126
+ Future<bool> isPermissionGranted() async {
127
+ final androidImplementation = _notifications
128
+ .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
129
+ if (androidImplementation != null) {
130
+ return await androidImplementation.areNotificationsEnabled() ?? false;
131
+ }
132
+
133
+ final iosImplementation = _notifications
134
+ .resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
135
+ if (iosImplementation != null) {
136
+ // In flutter_local_notifications, there isn't a direct "check" method for iOS permissions
137
+ // that returns a bool without requesting. However, we can use the resolvePlatformSpecificImplementation
138
+ // or assume it's false if we haven't asked.
139
+ // For now, we return false to trigger the banner which will then call requestPermission.
140
+ return false;
141
+ }
142
+
143
+ return false;
144
+ }
145
+
146
+ Future<bool> requestPermission() async {
147
+ final androidImplementation = _notifications
148
+ .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
149
+ if (androidImplementation != null) {
150
+ final granted = await androidImplementation.requestNotificationsPermission();
151
+ return granted ?? false;
152
+ }
153
+
154
+ final iosImplementation = _notifications
155
+ .resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
156
+ if (iosImplementation != null) {
157
+ final granted = await iosImplementation.requestPermissions(
158
+ alert: true,
159
+ badge: true,
160
+ sound: true,
161
+ );
162
+ return granted ?? false;
163
+ }
164
+
165
+ return false;
166
+ }
167
}
lib/features/onboarding/screens/onboarding_notification_permission_screen.dart
new
+183
@@ -0,0 +1,183 @@
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:hugeicons/hugeicons.dart';
5
+import 'package:shadcn_ui/shadcn_ui.dart';
6
+
7
+import '../../../core/providers/app_providers.dart';
8
+import '../../../core/routes/app_routes.dart';
9
+import '../../on_device/providers/on_device_providers.dart';
10
+
11
+class OnboardingNotificationPermissionScreen extends ConsumerStatefulWidget {
12
+ const OnboardingNotificationPermissionScreen({super.key});
13
+
14
+ @override
15
+ ConsumerState<OnboardingNotificationPermissionScreen> createState() =>
16
+ _OnboardingNotificationPermissionScreenState();
17
+}
18
+
19
+class _OnboardingNotificationPermissionScreenState
20
+ extends ConsumerState<OnboardingNotificationPermissionScreen> {
21
+ bool _isProcessing = false;
22
+
23
+ Future<void> _completeOnboarding() async {
24
+ final settings = ref.read(settingsProvider);
25
+ await ref
26
+ .read(settingsProvider.notifier)
27
+ .updateSettings(
28
+ settings.copyWith(
29
+ hasCompletedOnboarding: true,
30
+ hasAskedForNotifications: true,
31
+ ),
32
+ );
33
+
34
+ if (mounted) {
35
+ context.go(AppRoutes.home);
36
+ }
37
+ }
38
+
39
+ Future<void> _requestPermission() async {
40
+ setState(() => _isProcessing = true);
41
+ try {
42
+ final notificationService = ref.read(downloadNotificationServiceProvider);
43
+ await notificationService.requestPermission();
44
+ await _completeOnboarding();
45
+ } finally {
46
+ if (mounted) {
47
+ setState(() => _isProcessing = false);
48
+ }
49
+ }
50
+ }
51
+
52
+ @override
53
+ Widget build(BuildContext context) {
54
+ final theme = Theme.of(context);
55
+
56
+ return Scaffold(
57
+ body: SafeArea(
58
+ child: Padding(
59
+ padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 32.0),
60
+ child: Column(
61
+ crossAxisAlignment: CrossAxisAlignment.center,
62
+ children: [
63
+ const Spacer(),
64
+ // Visual element
65
+ Container(
66
+ padding: const EdgeInsets.all(32),
67
+ decoration: BoxDecoration(
68
+ color: theme.colorScheme.primaryContainer.withValues(
69
+ alpha: 0.2,
70
+ ),
71
+ shape: BoxShape.circle,
72
+ ),
73
+ child: HugeIcon(
74
+ icon: HugeIcons.strokeRoundedNotification03,
75
+ size: 80,
76
+ color: theme.colorScheme.primary,
77
+ ),
78
+ ),
79
+ const SizedBox(height: 48),
80
+ Text(
81
+ 'Stay Updated',
82
+ style: theme.textTheme.displaySmall?.copyWith(
83
+ fontWeight: FontWeight.bold,
84
+ ),
85
+ textAlign: TextAlign.center,
86
+ ),
87
+ const SizedBox(height: 16),
88
+ Text(
89
+ 'Get notified when your AI models finish downloading or when long-running tasks complete.',
90
+ style: theme.textTheme.titleMedium?.copyWith(
91
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
92
+ ),
93
+ textAlign: TextAlign.center,
94
+ ),
95
+ const SizedBox(height: 48),
96
+ // Benefits list
97
+ _buildBenefitRow(
98
+ context,
99
+ HugeIcons.strokeRoundedDownload01,
100
+ 'Model download progress',
101
+ ),
102
+ const SizedBox(height: 16),
103
+ _buildBenefitRow(
104
+ context,
105
+ HugeIcons.strokeRoundedAiChat01,
106
+ 'Generation completions',
107
+ ),
108
+ const SizedBox(height: 16),
109
+ _buildBenefitRow(
110
+ context,
111
+ HugeIcons.strokeRoundedClock01,
112
+ 'Background tasks status',
113
+ ),
114
+ const Spacer(),
115
+ const SizedBox(height: 32),
116
+ Column(
117
+ crossAxisAlignment: CrossAxisAlignment.stretch,
118
+ children: [
119
+ ShadButton(
120
+ onPressed: _isProcessing ? null : _requestPermission,
121
+ child: _isProcessing
122
+ ? const SizedBox(
123
+ height: 20,
124
+ width: 20,
125
+ child: CircularProgressIndicator(
126
+ strokeWidth: 2,
127
+ valueColor: AlwaysStoppedAnimation(Colors.white),
128
+ ),
129
+ )
130
+ : const Text(
131
+ 'Allow Notifications',
132
+ style: TextStyle(
133
+ fontSize: 16,
134
+ fontWeight: FontWeight.w600,
135
+ ),
136
+ ),
137
+ ),
138
+ const SizedBox(height: 12),
139
+ ShadButton.ghost(
140
+ onPressed: _isProcessing ? null : _completeOnboarding,
141
+ child: Text(
142
+ 'Not Now',
143
+ style: TextStyle(
144
+ color: theme.colorScheme.onSurface.withValues(
145
+ alpha: 0.6,
146
+ ),
147
+ ),
148
+ ),
149
+ ),
150
+ ],
151
+ ),
152
+ ],
153
+ ),
154
+ ),
155
+ ),
156
+ );
157
+ }
158
+
159
+ Widget _buildBenefitRow(
160
+ BuildContext context,
161
+ List<List<dynamic>> icon,
162
+ String text,
163
+ ) {
164
+ final theme = Theme.of(context);
165
+ return Row(
166
+ mainAxisSize: MainAxisSize.min,
167
+ children: [
168
+ HugeIcon(
169
+ icon: icon,
170
+ size: 20,
171
+ color: theme.colorScheme.primary.withValues(alpha: 0.8),
172
+ ),
173
+ const SizedBox(width: 12),
174
+ Text(
175
+ text,
176
+ style: theme.textTheme.bodyLarge?.copyWith(
177
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.9),
178
+ ),
179
+ ),
180
+ ],
181
+ );
182
+ }
183
+}
lib/features/onboarding/screens/onboarding_theme_screen.dart
+1
-9
@@ -112,16 +112,8 @@ class _OnboardingThemeScreenState extends ConsumerState<OnboardingThemeScreen> {
112
ShadButton(
113
width: double.infinity,
114
onPressed: () async {
115
- // Mark onboarding as complete
116
- final settings = ref.read(settingsProvider);
117
- await ref
118
- .read(settingsProvider.notifier)
119
- .updateSettings(
120
- settings.copyWith(hasCompletedOnboarding: true),
121
- );
122
-
115
if (context.mounted) {
124
- context.go(AppRoutes.home);
116
+ context.push(AppRoutes.onboardingNotifications);
117
}
118
},
119
child: const Text(
lib/features/settings/data/models/app_settings.dart
+7
@@ -25,6 +25,7 @@ class AppSettings {
25
final bool streamingEnabled;
26
final String? defaultPersonaId;
27
final bool hasCompletedOnboarding;
28
+ final bool hasAskedForNotifications;
29
final bool mcpEnabled;
30
final SyntaxThemeName codeThemeDark;
31
final SyntaxThemeName codeThemeLight;
@@ -46,6 +47,7 @@ class AppSettings {
47
this.streamingEnabled = true,
48
this.defaultPersonaId,
49
this.hasCompletedOnboarding = false,
50
+ this.hasAskedForNotifications = false,
51
this.mcpEnabled = true,
52
this.codeThemeDark = SyntaxThemeName.dark,
53
this.codeThemeLight = SyntaxThemeName.light,
@@ -68,6 +70,7 @@ class AppSettings {
70
bool? streamingEnabled,
71
String? defaultPersonaId,
72
bool? hasCompletedOnboarding,
73
+ bool? hasAskedForNotifications,
74
bool? mcpEnabled,
75
SyntaxThemeName? codeThemeDark,
76
SyntaxThemeName? codeThemeLight,
@@ -91,6 +94,8 @@ class AppSettings {
94
defaultPersonaId: defaultPersonaId ?? this.defaultPersonaId,
95
hasCompletedOnboarding:
96
hasCompletedOnboarding ?? this.hasCompletedOnboarding,
97
+ hasAskedForNotifications:
98
+ hasAskedForNotifications ?? this.hasAskedForNotifications,
99
mcpEnabled: mcpEnabled ?? this.mcpEnabled,
100
codeThemeDark: codeThemeDark ?? this.codeThemeDark,
101
codeThemeLight: codeThemeLight ?? this.codeThemeLight,
@@ -115,6 +120,7 @@ class AppSettings {
120
'streamingEnabled': streamingEnabled,
121
'defaultPersonaId': defaultPersonaId,
122
'hasCompletedOnboarding': hasCompletedOnboarding,
123
+ 'hasAskedForNotifications': hasAskedForNotifications,
124
'mcpEnabled': mcpEnabled,
125
'codeThemeDark': codeThemeDark.index,
126
'codeThemeLight': codeThemeLight.index,
@@ -139,6 +145,7 @@ class AppSettings {
145
streamingEnabled: map['streamingEnabled'] ?? true,
146
defaultPersonaId: map['defaultPersonaId'],
147
hasCompletedOnboarding: map['hasCompletedOnboarding'] ?? false,
148
+ hasAskedForNotifications: map['hasAskedForNotifications'] ?? false,
149
mcpEnabled: map['mcpEnabled'] ?? true,
150
codeThemeDark: SyntaxThemeName.values[map['codeThemeDark'] ?? 0],
151
codeThemeLight: SyntaxThemeName.values[map['codeThemeLight'] ?? 1],
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.2.0+15
19
+version: 1.2.0+17
20
21
environment:
22
sdk: ^3.11.0