@tduncan / Cortana / commits / 9834134

feat: implement notification permission onboarding flow and migrate Android foreground service to short service type

abdulmominsakib committed Apr 20, 2026 at 15:00 UTC 98341341837785a9de697a78b3865fcf07d942e3
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