Sharing a large file to Noo used to black-screen and silently kick back to the home screen: receive_sharing_intent copies the entire shared file into the cache dir synchronously on the main thread before Flutter even renders, which Android's watchdog eventually kills. Replaced with hand-rolled handling in MainActivity.kt that only ever reads cheap Uri metadata up front (ShareIntentService), so the destination picker always appears instantly regardless of file size. The destination picker (ShareUploadView) now mirrors the Files tab's own controls/filters/listing instead of a bare folder list, and the actual prepare+upload is handed off to ShareUploadService.kt, a real Android foreground service with a single cancellable progress notification for the whole batch - this survives the app being closed right after the user confirms a destination, the same guarantee a file-manager app's own upload notification gives. Also fixes the "add another account" login page: it used to open in url_launcher's bare inAppWebView, which has no close button and can hide the status bar. LoginWebViewView is a real screen this app owns instead (package:webview_flutter), with a normal AppBar/close button/safe area, and can now auto-close itself on successful login. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
232 lines
7.4 KiB
Dart
232 lines
7.4 KiB
Dart
import 'dart:async';
|
|
import 'package:dynamic_color/dynamic_color.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'models/app_tab.dart';
|
|
import 'providers/server_provider.dart';
|
|
import 'services/share_intent_service.dart';
|
|
import 'theme/app_theme.dart';
|
|
import 'views/lock_screen_view.dart';
|
|
import 'views/login_view.dart';
|
|
import 'views/search_view.dart';
|
|
import 'views/share_upload_view.dart';
|
|
import 'widgets/app_tab_view_builder.dart';
|
|
import 'widgets/floating_bottom_bar.dart';
|
|
|
|
void main() {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
runApp(
|
|
ChangeNotifierProvider(
|
|
create: (_) => ServerProvider(),
|
|
child: const NextcloudApp(),
|
|
),
|
|
);
|
|
}
|
|
|
|
class NextcloudApp extends StatelessWidget {
|
|
const NextcloudApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final provider = context.watch<ServerProvider>();
|
|
|
|
return DynamicColorBuilder(
|
|
builder: (lightDynamic, darkDynamic) {
|
|
return MaterialApp(
|
|
title: 'Noo',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: AppTheme.light(
|
|
provider.seedColor,
|
|
dynamicScheme: lightDynamic,
|
|
useDynamicColor: provider.useDynamicColor,
|
|
),
|
|
darkTheme: AppTheme.dark(
|
|
provider.seedColor,
|
|
dynamicScheme: darkDynamic,
|
|
useDynamicColor: provider.useDynamicColor,
|
|
amoled: provider.amoledDark,
|
|
),
|
|
themeMode: provider.themeMode,
|
|
home: provider.isRestoringSession
|
|
? const _SplashView()
|
|
: (!provider.isLoggedIn
|
|
? const LoginView()
|
|
: (provider.needsUnlock
|
|
? const LockScreenView()
|
|
: const MainShellView())),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SplashView extends StatelessWidget {
|
|
const _SplashView();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Scaffold(
|
|
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 the theme's foreground so it reads
|
|
// correctly in both light and dark mode.
|
|
ColorFiltered(
|
|
colorFilter: ColorFilter.mode(
|
|
colorScheme.onSurface,
|
|
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: colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MainShellView extends StatefulWidget {
|
|
const MainShellView({super.key});
|
|
|
|
@override
|
|
State<MainShellView> createState() => _MainShellViewState();
|
|
}
|
|
|
|
class _MainShellViewState extends State<MainShellView> {
|
|
late AppTab _currentTab;
|
|
late final Map<AppTab, ScrollController> _scrollControllers;
|
|
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
|
|
|
@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<ServerProvider>().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);
|
|
}
|
|
|
|
void _handleSharedFiles(List<SharedFileRef> 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();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final provider = context.watch<ServerProvider>();
|
|
|
|
// 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 = provider.requestedTab;
|
|
if (requestedTab != null) {
|
|
_currentTab = requestedTab;
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => provider.consumeRequestedTab(),
|
|
);
|
|
}
|
|
|
|
final visible = provider.visibleTabs;
|
|
final selectedTab = visible.contains(_currentTab)
|
|
? _currentTab
|
|
: (visible.isNotEmpty ? visible.first : AppTab.files);
|
|
final selectedIndex = visible
|
|
.indexOf(selectedTab)
|
|
.clamp(0, visible.isEmpty ? 0 : visible.length - 1);
|
|
|
|
final navItems = visible
|
|
.map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon))
|
|
.toList();
|
|
|
|
return Scaffold(
|
|
body: Stack(
|
|
children: [
|
|
IndexedStack(
|
|
index: selectedIndex,
|
|
children: visible
|
|
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
|
|
.toList(),
|
|
),
|
|
// Each tab now renders its own sticky selection toolbar inline
|
|
// (right under its sort/filter row) instead of this shell
|
|
// swapping in a shared floating bar - the bottom nav stays put
|
|
// and usable regardless of selection state.
|
|
FloatingBottomNavBar(
|
|
selectedIndex: selectedIndex,
|
|
items: navItems,
|
|
opacity: provider.bottomBarOpacity,
|
|
blurSigma: provider.bottomBarBlur,
|
|
onDestinationSelected: (index) {
|
|
final tappedTab = visible[index];
|
|
if (tappedTab == _currentTab) {
|
|
if (provider.tapTabToScrollTop) {
|
|
final controller = _scrollControllers[tappedTab];
|
|
if (controller != null && controller.hasClients) {
|
|
controller.animateTo(
|
|
0,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
setState(() {
|
|
_currentTab = tappedTab;
|
|
});
|
|
},
|
|
onSearchTap: () {
|
|
Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute(builder: (_) => const SearchView()));
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|