diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 5f43268..2dec69a 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -125,6 +125,19 @@ another app" story) that feeds `ServerProvider.pickRequest`; while 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 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 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 controls-row/breadcrumbs sticky header almost exactly, so arriving via another app's "Share to..." sheet still lands on the same top chrome -instead of a plain `AppBar` - its `actions` are the exact same -`[MoreTabsButton(), ProfileAvatarButton()]` every non-Files tab uses (kept -uniform deliberately; backing out is the system back gesture/button, not a -bespoke close icon in the app bar). Its bottom action - "Upload to +instead of a plain `AppBar` - its `actions` are `[ProfileAvatarButton()]` +only (no `MoreTabsButton`: there's nowhere useful for it to go mid-upload, +since jumping to another tab would abandon the destination picker); +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, auto-scrolling via `MarqueeTitle` if it doesn't fit) live together in one rounded-top, elevated `Material` bar as `bottomNavigationBar`, reading as a diff --git a/lib/main.dart b/lib/main.dart index d4b43bd..675b901 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'models/app_tab.dart'; import 'models/pick_request.dart'; import 'providers/server_provider.dart'; @@ -147,6 +149,53 @@ class _MainShellViewState extends State { _pickSub = PickIntentService.onNewPickRequest.listen((request) { if (mounted) context.read().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 _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( + 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 files) { diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index ecff13e..4c47aeb 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -80,28 +80,6 @@ class AccountView extends StatelessWidget { const SizedBox(height: 16), const Divider(height: 1), 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( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -135,19 +113,33 @@ class AccountView extends StatelessWidget { ), ), const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: Text( - quota != null - ? (quota.totalBytes > 0 - ? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used' - : 'Unlimited storage') - : '', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + Uri.tryParse(provider.serverUrl)?.host ?? + provider.serverUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + 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) ...[ const SizedBox(height: 16), diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index 6eb470e..6410dc2 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -10,7 +10,6 @@ import '../services/upload_service.dart'; import '../widgets/breadcrumbs.dart'; import '../widgets/item_icon.dart'; import '../widgets/marquee_title.dart'; -import '../widgets/more_tabs_button.dart'; import '../widgets/profile_avatar_button.dart'; import '../widgets/segmented_icon_toggle.dart'; import '../widgets/sort_menu_button.dart'; @@ -413,11 +412,13 @@ class _ShareUploadViewState extends State { body: SyncedHeaderScaffold( scrollController: _scrollController, provider: provider, - // Same trailing actions as every other tab - no bespoke close - // button here, so the top chrome is identical regardless of how - // this screen was reached. Backing out is still the system - // back gesture/button, same as any other pushed screen. - actions: const [MoreTabsButton(), ProfileAvatarButton()], + // Same trailing actions as every other tab except "More tabs" - + // there's nowhere useful for it to go while mid-upload (jumping to + // Trash/Shares/etc. would abandon this destination picker), so the + // top chrome is identical to every other tab minus that one entry. + // Backing out is still the system back gesture/button, same as any + // other pushed screen. + actions: const [ProfileAvatarButton()], contentSlivers: contentSlivers, ), // A rounded-top, elevated bar "peeking" up from the bottom edge - the diff --git a/lib/widgets/floating_bottom_bar.dart b/lib/widgets/floating_bottom_bar.dart index e2fdd2f..b8d8298 100644 --- a/lib/widgets/floating_bottom_bar.dart +++ b/lib/widgets/floating_bottom_bar.dart @@ -61,57 +61,68 @@ class FloatingBottomNavBar extends StatelessWidget { blurSigma: blurSigma, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(items.length, (index) { - final item = items[index]; - final isSelected = selectedIndex == index; + // The selected item's label expands the pill, and with enough + // tabs visible that can outgrow the available width (the + // ConstrainedBox above caps it at 480, but the screen itself may + // be narrower) - wrapped in a scroll view rather than a plain Row + // 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( - onTap: () => onDestinationSelected(index), - borderRadius: BorderRadius.circular(26), - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: Colors.transparent, - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOutCubic, - padding: EdgeInsets.symmetric( - horizontal: isSelected ? 16 : 12, - vertical: 12, - ), - decoration: BoxDecoration( - color: isSelected ? colorScheme.primary : Colors.transparent, - borderRadius: BorderRadius.circular(26), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - item.icon, - size: 22, - color: isSelected - ? colorScheme.onPrimary - : colorScheme.onSurfaceVariant, - ), - 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, - ), + return InkWell( + onTap: () => onDestinationSelected(index), + borderRadius: BorderRadius.circular(26), + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: Colors.transparent, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOutCubic, + padding: EdgeInsets.symmetric( + horizontal: isSelected ? 16 : 12, + vertical: 12, + ), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + borderRadius: BorderRadius.circular(26), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + item.icon, + size: 22, + color: isSelected + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, ), + 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, + ), + ), + ], ], - ], + ), ), - ), - ); - }), + ); + }), + ), ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 8627857..66a1b9c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -712,6 +712,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1f72cdb..fc892c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: marquee: ^2.3.0 local_auth: ^2.3.0 webview_flutter: ^4.14.1 + permission_handler: ^11.3.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bac2c28..b9467ec 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -25,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("LocalAuthPlugin")); PdfxPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PdfxPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 947569c..21e18a6 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows local_auth_windows pdfx + permission_handler_windows share_plus url_launcher_windows )