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:
@@ -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<MainShellView> {
|
||||
_pickSub = PickIntentService.onNewPickRequest.listen((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) {
|
||||
|
||||
+26
-34
@@ -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),
|
||||
|
||||
@@ -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<ShareUploadView> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user