Add launch-time notification permission prompt and UI polish
Build APK / build (push) Successful in 6m20s

- Request POST_NOTIFICATIONS once on first app launch, with a
  plain-language rationale dialog shown before the OS prompt
  (permission_handler), since upload/download progress relies on it.
- ShareUploadView no longer shows "More tabs" - there's nowhere useful
  for it to go mid-upload.
- FloatingBottomNavBar's pill scrolls horizontally instead of
  overflowing when the visible tab set doesn't fit.
- Settings account card: drop the separate server-address row and its
  globe icon, showing the host inline with the quota percentage/
  "Unlimited storage" text instead (left-aligned, same size).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 16:52:16 -04:00
co-authored by Claude Sonnet 5
parent b1400e399f
commit dd0c27035a
9 changed files with 211 additions and 91 deletions
+18 -4
View File
@@ -125,6 +125,19 @@ another app" story) that feeds `ServerProvider.pickRequest`; while
regardless of the user's own hidden/reordered tab settings, since those regardless of the user's own hidden/reordered tab settings, since those
are the only two views that know how to handle a picking-mode tap and the are the only two views that know how to handle a picking-mode tap and the
only two that make sense as external "choose a file" sources. only two that make sense as external "choose a file" sources.
`MainShellView` also fires a one-time notification-permission prompt on
its first mount (`_maybeRequestNotificationPermission`, gated by a plain
`shared_preferences` flag so it only ever asks once, not on every
launch): a plain-language `AlertDialog` explaining why Noo wants it
(upload/download progress notifications - see `ShareUploadService.kt`/
`DownloadService.kt`) before the OS's own `permission_handler`-driven
`Permission.notification.request()`, since the bare system prompt gives
no context on its own.
`FloatingBottomNavBar` (`widgets/floating_bottom_bar.dart`) wraps its
pill's item row in a horizontal `SingleChildScrollView` rather than a
plain `Row`, so a wide selected-item label plus several icon-only tabs
scrolls instead of overflowing on narrower screens.
All six tabs, plus `ShareUploadView` (the share-to-upload destination All six tabs, plus `ShareUploadView` (the share-to-upload destination
picker, pushed rather than a tab - see below), share picker, pushed rather than a tab - see below), share
@@ -134,10 +147,11 @@ Photos-style) and a classic Material refresh spinner shown during a
pull-triggered sync. `ShareUploadView` mirrors the Files tab's own pull-triggered sync. `ShareUploadView` mirrors the Files tab's own
controls-row/breadcrumbs sticky header almost exactly, so arriving via controls-row/breadcrumbs sticky header almost exactly, so arriving via
another app's "Share to..." sheet still lands on the same top chrome another app's "Share to..." sheet still lands on the same top chrome
instead of a plain `AppBar` - its `actions` are the exact same instead of a plain `AppBar` - its `actions` are `[ProfileAvatarButton()]`
`[MoreTabsButton(), ProfileAvatarButton()]` every non-Files tab uses (kept only (no `MoreTabsButton`: there's nowhere useful for it to go mid-upload,
uniform deliberately; backing out is the system back gesture/button, not a since jumping to another tab would abandon the destination picker);
bespoke close icon in the app bar). Its bottom action - "Upload to backing out is the system back gesture/button, not a bespoke close icon in
the app bar. Its bottom action - "Upload to
{folder}" - and the uploading-file-name summary above it (single line, {folder}" - and the uploading-file-name summary above it (single line,
auto-scrolling via `MarqueeTitle` if it doesn't fit) live together in one auto-scrolling via `MarqueeTitle` if it doesn't fit) live together in one
rounded-top, elevated `Material` bar as `bottomNavigationBar`, reading as a rounded-top, elevated `Material` bar as `bottomNavigationBar`, reading as a
+49
View File
@@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/app_tab.dart'; import 'models/app_tab.dart';
import 'models/pick_request.dart'; import 'models/pick_request.dart';
import 'providers/server_provider.dart'; import 'providers/server_provider.dart';
@@ -147,6 +149,53 @@ class _MainShellViewState extends State<MainShellView> {
_pickSub = PickIntentService.onNewPickRequest.listen((request) { _pickSub = PickIntentService.onNewPickRequest.listen((request) {
if (mounted) context.read<ServerProvider>().setPickRequest(request); if (mounted) context.read<ServerProvider>().setPickRequest(request);
}); });
WidgetsBinding.instance.addPostFrameCallback(
(_) => _maybeRequestNotificationPermission(),
);
}
static const _prefNotificationPermissionAsked =
'notification_permission_asked';
// Asked once, on the app's first launch after login, rather than every
// time this shell mounts - a plain-language reason shown before the OS
// prompt (which just says "Noo would like to send you notifications"
// with no context) so the system dialog doesn't feel unexplained, since
// it's what shows upload/download progress for background transfers.
Future<void> _maybeRequestNotificationPermission() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_prefNotificationPermissionAsked) ?? false) return;
await prefs.setBool(_prefNotificationPermissionAsked, true);
final status = await Permission.notification.status;
if (status.isGranted || status.isPermanentlyDenied) return;
if (!mounted) return;
final shouldRequest = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Allow notifications?'),
content: const Text(
'Noo shows upload and download progress as a notification, so '
'you can track transfers that keep running in the background.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Not now'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Allow'),
),
],
),
);
if (shouldRequest == true) {
await Permission.notification.request();
}
} }
void _handleSharedFiles(List<SharedFileRef> files) { void _handleSharedFiles(List<SharedFileRef> files) {
+26 -34
View File
@@ -80,28 +80,6 @@ class AccountView extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 16), const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.public_rounded,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
Uri.tryParse(provider.serverUrl)?.host ??
provider.serverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -135,19 +113,33 @@ class AccountView extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Align( Row(
alignment: Alignment.centerRight, mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Text( children: [
quota != null Expanded(
? (quota.totalBytes > 0 child: Text(
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used' Uri.tryParse(provider.serverUrl)?.host ??
: 'Unlimited storage') provider.serverUrl,
: '', maxLines: 1,
style: theme.textTheme.bodySmall?.copyWith( overflow: TextOverflow.ellipsis,
color: colorScheme.onSurfaceVariant, style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11, color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
), ),
), Text(
quota != null
? (quota.totalBytes > 0
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
: 'Unlimited storage')
: '',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
), ),
if (quota != null && quota.groups.isNotEmpty) ...[ if (quota != null && quota.groups.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
+7 -6
View File
@@ -10,7 +10,6 @@ import '../services/upload_service.dart';
import '../widgets/breadcrumbs.dart'; import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart'; import '../widgets/item_icon.dart';
import '../widgets/marquee_title.dart'; import '../widgets/marquee_title.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart'; import '../widgets/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.dart'; import '../widgets/sort_menu_button.dart';
@@ -413,11 +412,13 @@ class _ShareUploadViewState extends State<ShareUploadView> {
body: SyncedHeaderScaffold( body: SyncedHeaderScaffold(
scrollController: _scrollController, scrollController: _scrollController,
provider: provider, provider: provider,
// Same trailing actions as every other tab - no bespoke close // Same trailing actions as every other tab except "More tabs" -
// button here, so the top chrome is identical regardless of how // there's nowhere useful for it to go while mid-upload (jumping to
// this screen was reached. Backing out is still the system // Trash/Shares/etc. would abandon this destination picker), so the
// back gesture/button, same as any other pushed screen. // top chrome is identical to every other tab minus that one entry.
actions: const [MoreTabsButton(), ProfileAvatarButton()], // Backing out is still the system back gesture/button, same as any
// other pushed screen.
actions: const [ProfileAvatarButton()],
contentSlivers: contentSlivers, contentSlivers: contentSlivers,
), ),
// A rounded-top, elevated bar "peeking" up from the bottom edge - the // A rounded-top, elevated bar "peeking" up from the bottom edge - the
+58 -47
View File
@@ -61,57 +61,68 @@ class FloatingBottomNavBar extends StatelessWidget {
blurSigma: blurSigma, blurSigma: blurSigma,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
child: Row( // The selected item's label expands the pill, and with enough
mainAxisSize: MainAxisSize.min, // tabs visible that can outgrow the available width (the
children: List.generate(items.length, (index) { // ConstrainedBox above caps it at 480, but the screen itself may
final item = items[index]; // be narrower) - wrapped in a scroll view rather than a plain Row
final isSelected = selectedIndex == index; // so it degrades to a swipe instead of overflowing/clipping.
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(items.length, (index) {
final item = items[index];
final isSelected = selectedIndex == index;
return InkWell( return InkWell(
onTap: () => onDestinationSelected(index), onTap: () => onDestinationSelected(index),
borderRadius: BorderRadius.circular(26), borderRadius: BorderRadius.circular(26),
splashColor: colorScheme.primary.withValues(alpha: 0.12), splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeInOutCubic, curve: Curves.easeInOutCubic,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isSelected ? 16 : 12, horizontal: isSelected ? 16 : 12,
vertical: 12, vertical: 12,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent, color: isSelected
borderRadius: BorderRadius.circular(26), ? colorScheme.primary
), : Colors.transparent,
child: Row( borderRadius: BorderRadius.circular(26),
mainAxisSize: MainAxisSize.min, ),
children: [ child: Row(
Icon( mainAxisSize: MainAxisSize.min,
item.icon, children: [
size: 22, Icon(
color: isSelected item.icon,
? colorScheme.onPrimary size: 22,
: colorScheme.onSurfaceVariant, color: isSelected
), ? colorScheme.onPrimary
if (isSelected) ...[ : colorScheme.onSurfaceVariant,
const SizedBox(width: 8),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: colorScheme.onPrimary,
letterSpacing: 0.1,
),
), ),
if (isSelected) ...[
const SizedBox(width: 8),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: colorScheme.onPrimary,
letterSpacing: 0.1,
),
),
],
], ],
], ),
), ),
), );
); }),
}), ),
), ),
), ),
); );
+48
View File
@@ -712,6 +712,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8
url: "https://pub.dev"
source: hosted
version: "9.6.1"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac"
url: "https://pub.dev"
source: hosted
version: "0.1.4+1"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531
url: "https://pub.dev"
source: hosted
version: "4.4.1"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd
url: "https://pub.dev"
source: hosted
version: "0.2.2"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -55,6 +55,7 @@ dependencies:
marquee: ^2.3.0 marquee: ^2.3.0
local_auth: ^2.3.0 local_auth: ^2.3.0
webview_flutter: ^4.14.1 webview_flutter: ^4.14.1
permission_handler: ^11.3.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -11,6 +11,7 @@
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h> #include <local_auth_windows/local_auth_plugin.h>
#include <pdfx/pdfx_plugin.h> #include <pdfx/pdfx_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h> #include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
@@ -25,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("LocalAuthPlugin")); registry->GetRegistrarForPlugin("LocalAuthPlugin"));
PdfxPluginRegisterWithRegistrar( PdfxPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PdfxPlugin")); registry->GetRegistrarForPlugin("PdfxPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar( SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
+1
View File
@@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows flutter_secure_storage_windows
local_auth_windows local_auth_windows
pdfx pdfx
permission_handler_windows
share_plus share_plus
url_launcher_windows url_launcher_windows
) )