Add launch-time notification permission prompt and UI polish
Build APK / build (push) Successful in 6m20s
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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}),
|
||||||
}),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user