import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.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/connectivity_controller.dart'; import 'providers/favorites_controller.dart'; import 'providers/files_controller.dart'; import 'providers/item_operations.dart'; import 'providers/offline_controller.dart'; import 'providers/photos_controller.dart'; import 'providers/pick_controller.dart'; import 'providers/recent_controller.dart'; import 'providers/session_controller.dart'; import 'providers/settings_controller.dart'; import 'providers/shares_controller.dart'; import 'providers/sync_status_controller.dart'; import 'providers/trash_controller.dart'; import 'services/pick_intent_service.dart'; import 'services/share_intent_service.dart'; import 'theme/app_theme.dart'; import 'theme/design_tokens.dart'; import 'views/lock_screen_view.dart'; import 'views/login_view.dart'; import 'views/share_upload_view.dart'; import 'widgets/app_drawer.dart'; import 'widgets/app_tab_view_builder.dart'; import 'widgets/app_top_bar.dart'; import 'widgets/bottom_nav_bar.dart'; import 'widgets/create_menu.dart'; import 'widgets/noo/core/noo_avatar.dart'; import 'widgets/noo/core/noo_button.dart'; import 'widgets/noo/core/noo_fab.dart'; import 'widgets/noo/nav/noo_nav_style.dart'; import 'widgets/noo/nav/noo_sidebar.dart'; import 'widgets/noo/nav/noo_toolbar.dart'; import 'widgets/noo/noo_layout.dart'; import 'widgets/noo/overlays/noo_dialog.dart'; import 'widgets/shell/shell_common.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); runApp( MultiProvider( // Split out of the former single `ServerProvider` god object - see // `.claude/context/architecture.md`'s "State management" section. // Order matters: each `create` below reads earlier providers via // `context.read`, which is safe here since none of them are ever // replaced/recreated for the app's lifetime (account switching is // internal state on SessionController, not a new provider instance). providers: [ ChangeNotifierProvider(create: (_) => ConnectivityController()), ChangeNotifierProvider( create: (context) => SessionController(context.read()), ), ChangeNotifierProvider(create: (_) => SettingsController()), // Before SyncStatusController, which follows its Files Cache rule // for how often synced files refresh. ChangeNotifierProvider( create: (context) => FilesController(context.read()), ), ChangeNotifierProvider( // Eager (`lazy: false`) - this controller only loads its persisted // state when SessionController fires the account-ready event, and // that fires once at startup, before any widget has read this // provider. Lazily created, it would register its listener too // late and stay empty ("Sync off") until an account switch. lazy: false, create: (context) => SyncStatusController(context.read(), context.read()), ), ChangeNotifierProvider( create: (context) => PhotosController(context.read(), context.read()), ), ChangeNotifierProvider( create: (context) => FavoritesController(context.read(), context.read()), ), ChangeNotifierProvider( create: (context) => TrashController(context.read()), ), ChangeNotifierProvider( create: (context) => SharesController(context.read()), ), ChangeNotifierProvider( create: (context) => RecentController(context.read()), ), ChangeNotifierProvider( create: (context) => PickController(context.read()), ), ChangeNotifierProvider( // Eager for the same reason as SyncStatusController above. lazy: false, create: (context) => OfflineController(context.read(), context.read()), ), Provider( create: (context) => ItemOperations( session: context.read(), files: context.read(), photos: context.read(), favorites: context.read(), ), ), ], child: const NextcloudApp(), ), ); } class NextcloudApp extends StatelessWidget { const NextcloudApp({super.key}); @override Widget build(BuildContext context) { final settings = context.watch(); final session = context.watch(); return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) { return MaterialApp( title: 'Noo', debugShowCheckedModeBanner: false, theme: AppTheme.light( settings.seedColor, dynamicScheme: lightDynamic, useDynamicColor: settings.useDynamicColor, ), darkTheme: AppTheme.dark( settings.seedColor, dynamicScheme: darkDynamic, useDynamicColor: settings.useDynamicColor, amoled: settings.amoledDark, ), themeMode: settings.themeMode, home: session.isRestoringSession ? const _SplashView() : (!session.isLoggedIn ? const LoginView() : (session.needsUnlock ? const LockScreenView() : const MainShellView())), ); }, ); } } class _SplashView extends StatelessWidget { const _SplashView(); @override Widget build(BuildContext context) { final colors = context.nooColors; return Scaffold( backgroundColor: colors.bg, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // The monochrome adaptive-icon layer is a plain white silhouette // on transparent, meant to be tinted rather than shown as-is - // srcIn recolors it to fg-1 so it reads correctly in both light // and dark mode. ColorFiltered( colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn), child: Image.asset( 'assets/icon/app_icon_monochrome.png', width: 72, height: 72, ), ), const SizedBox(height: 24), SizedBox( width: 22, height: 22, child: CircularProgressIndicator( strokeWidth: 2.5, color: colors.accent, ), ), ], ), ), ); } } class MainShellView extends StatefulWidget { const MainShellView({super.key}); @override State createState() => _MainShellViewState(); } class _MainShellViewState extends State { late AppTab _currentTab; late final Map _scrollControllers; StreamSubscription>? _shareSub; StreamSubscription? _pickSub; @override void initState() { super.initState(); _scrollControllers = { for (final tab in AppTab.values) tab: ScrollController(), }; // Preferences load asynchronously, but in practice finish well before // session restore (which also has to hit the network) does, so by the // time this shell mounts `defaultTab` already reflects the saved value. _currentTab = context.read().defaultTab; // Handles both a cold start via another app's "Share to..." sheet // (getInitialShare) and a share arriving while the app is already // running (onNewShare). See MainActivity.kt's doc comment for why this // is hand-rolled instead of the receive_sharing_intent plugin - in // short, this only ever fetches cheap Uri metadata here, never a // file's actual bytes, so a large shared file can't block startup. ShareIntentService.getInitialShare().then(_handleSharedFiles); _shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles); // Same cold-start-vs-already-running split as the share intent above: // another app may have launched Noo as its GET_CONTENT picker. PickIntentService.getPickRequest().then((request) { if (request != null && mounted) { context.read().setPickRequest(request); } }); _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 colors = context.nooColors; final shouldRequest = await showNooDialog( context, title: 'Allow notifications?', barrierDismissible: false, children: [ Text( 'Noo shows upload and download progress as a notification, so ' 'you can track transfers that keep running in the background.', style: NooText.body.copyWith(color: colors.fg2), ), Row( mainAxisAlignment: MainAxisAlignment.end, spacing: 10, children: [ NooButton( variant: NooButtonVariant.secondary, onTap: () => Navigator.of(context).pop(false), child: const Text('Not now'), ), NooButton( variant: NooButtonVariant.primary, onTap: () => Navigator.of(context).pop(true), child: const Text('Allow'), ), ], ), ], ); if (shouldRequest == true) { await Permission.notification.request(); } } void _handleSharedFiles(List files) { if (files.isEmpty) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files))); }); } @override void dispose() { for (final c in _scrollControllers.values) { c.dispose(); } _shareSub?.cancel(); _pickSub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final colors = context.nooColors; final settings = context.watch(); final pick = context.watch(); final connectivity = context.watch(); final session = context.watch(); final quota = context.watch().quota; // Cheap and already loaded once the Trash tab has been visited this // session - not worth adding a fetch just to populate a nav badge. final trashCount = context.watch().items.length; // A one-shot request (e.g. tapping a search result) to switch tabs - // consumed here so it only fires once, then cleared after this frame // (clearing it synchronously would call notifyListeners mid-build). final requestedTab = settings.requestedTab; if (requestedTab != null) { _currentTab = requestedTab; WidgetsBinding.instance.addPostFrameCallback( (_) => settings.consumeRequestedTab(), ); } final pickRequest = pick.pickRequest; // While acting as another app's picker, only Files and Photos make // sense as browsable sources - Trash/Shares/Activity/Recent aren't // real "pick a file from here" destinations, and only these two tabs' // views even know how to handle picking-mode taps. This overrides the // user's own hidden/reordered tab settings rather than respecting // them, since picking is a separate mode from normal browsing. With // no network at all, every other tab would just show its own loading // spinner forever/an error state, so the nav collapses to just Offline // - the one tab that works without a connection (see // `FilesView(offline: true)`/`ConnectivityController`). final overrideActive = pickRequest != null || connectivity.isOffline; final pinnedTabs = pickRequest != null ? [AppTab.files, AppTab.photos] : connectivity.isOffline ? [AppTab.offline] : settings.visibleTabs; // The drawer/sidebar's "More" section - hidden while an override above // is active, same as the drawer itself being unavailable during pick. final hiddenTabs = overrideActive ? const [] : settings.tabOrder .where((t) => settings.hiddenTabs.contains(t)) .toList(); // A tab opened from "More" isn't one of the pinned destinations, but // should stay shown and selected (with no destination highlighted in // the bottom bar/sidebar) until the user taps a pinned or another // "More" tab. final displayTabs = !overrideActive && !pinnedTabs.contains(_currentTab) ? [...pinnedTabs, _currentTab] : pinnedTabs; final selectedTab = displayTabs.contains(_currentTab) ? _currentTab : (displayTabs.isNotEmpty ? displayTabs.first : AppTab.files); final selectedIndex = displayTabs .indexOf(selectedTab) .clamp(0, displayTabs.isEmpty ? 0 : displayTabs.length - 1); final pinnedIndex = pinnedTabs.indexOf(selectedTab); void selectTab(AppTab tab) { if (tab == _currentTab) return; setState(() => _currentTab = tab); } String? trashBadge(AppTab tab) => tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null; // Each tab renders its own sticky selection toolbar inline (right under // its sort/filter row) instead of the shell swapping in a shared one, // so the nav chrome stays put and usable regardless of selection state. final tabStack = Stack( children: [ IndexedStack( index: selectedIndex, children: displayTabs .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) .toList(), ), if (pick.isDownloadingForPick) const _PickingProgressOverlay(), ], ); final canUpload = pickRequest == null && (selectedTab == AppTab.files || selectedTab == AppTab.photos); Widget scaffold; if (NooLayout.isDesktop(context)) { scaffold = Scaffold( backgroundColor: colors.bg, body: Row( children: [ NooSidebar( account: NooSidebarAccount( avatar: NooAvatar( initials: accountInitial(session.username), current: true, size: 32, ), name: session.username, subtitle: serverHost(session.serverUrl), onTap: () => showAccountSwitcher(context), ), items: [ for (final tab in pinnedTabs) NooSidebarItem( icon: tab.icon, label: tab.label, count: trashBadge(tab), selected: tab == selectedTab, onTap: () => selectTab(tab), ), if (hiddenTabs.isNotEmpty) const NooSidebarDivider(), for (final tab in hiddenTabs) NooSidebarItem( icon: tab.icon, label: tab.label, count: trashBadge(tab), selected: tab == selectedTab, onTap: () => selectTab(tab), ), ], storage: NooSidebarStorage( value: quotaFraction(quota), detail: quotaDetail(quota), ), settings: NooSidebarItem( icon: LucideIcons.settings, label: 'Settings', onTap: () => openSettings(context), ), ), Expanded( child: Column( children: [ NooToolbar( title: selectedTab.label, search: const ShellSearchLauncher(onSurface: true), actions: [ if (canUpload) NooButton( icon: LucideIcons.upload, onTap: () => showCreateMenu(context), child: const Text('Upload'), ), ], ), Expanded( child: ColoredBox(color: colors.surface, child: tabStack), ), ], ), ), ], ), ); } else { final navStyle = NooLayout.navStyle(context); scaffold = Scaffold( backgroundColor: colors.bg, drawerScrimColor: colors.scrim, appBar: pickRequest == null ? AppTopBar(style: navStyle, tab: selectedTab) : null, drawer: pickRequest == null ? const AppDrawer() : null, // Android-only extended Upload FAB - iOS uses the top bar's `plus` // instead (see AppTopBar). Stays mounted across every tab (picking // aside) and collapses to an icon-only circle off Files/Photos, // rather than the Scaffold popping it fully in/out on every tab // switch - see NooFab's [collapsed]. floatingActionButton: pickRequest == null && navStyle == NooNavStyle.android ? NooFab( collapsed: !canUpload, onTap: () => showCreateMenu(context), ) : null, body: tabStack, bottomNavigationBar: BottomNavBar( style: navStyle, tabs: pinnedTabs, selectedIndex: pinnedIndex, onDestinationSelected: (index) { final tappedTab = pinnedTabs[index]; if (tappedTab == _currentTab) { if (settings.tapTabToScrollTop) { final controller = _scrollControllers[tappedTab]; // `hasClients` only means a position is attached - not that // it's finished its first layout. Calling animateTo before // that throws deep inside Flutter's ballistic-scroll code // (min/maxScrollExtent are still null), and since this // fires synchronously from the nav bar's tap handler, the // exception corrupts that tab's scroll view instead of // just being logged - a real crash seen in the wild // (Files rendering empty after re-tapping its own tab). if (controller != null && controller.hasClients && controller.position.hasContentDimensions) { controller.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, ); } } return; } selectTab(tappedTab); }, ), ); } return PopScope( // While picking, system back cancels the pick (and tells the caller // nothing was chosen) instead of exiting the app from under it - // there's no Navigator route to pop at this, the root screen. canPop: pickRequest == null, onPopInvokedWithResult: (didPop, result) { if (!didPop && pickRequest != null) pick.cancelPick(); }, child: scaffold, ); } } /// Blocks interaction while the selected file(s) download to a local cache /// folder before being handed back to the caller (see /// `PickController.confirmPick`) - can take a moment for a large file/video. class _PickingProgressOverlay extends StatelessWidget { const _PickingProgressOverlay(); @override Widget build(BuildContext context) { final colors = context.nooColors; return Positioned.fill( child: ColoredBox( color: colors.scrim, child: Center( child: Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(NooRadii.dialog), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: colors.accent), const SizedBox(height: 16), Text( 'Preparing file…', style: NooText.body.copyWith(color: colors.fg1), ), ], ), ), ), ), ); } }