Release 1.0.3: refine tablet layouts, pointer selection and account sync cleanup
This commit is contained in:
+40
-16
@@ -35,8 +35,8 @@ import 'widgets/app_top_bar.dart';
|
||||
import 'widgets/avatar_menu.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_pointer_selection.dart';
|
||||
import 'widgets/noo/core/noo_fab.dart';
|
||||
import 'widgets/noo/nav/noo_bottom_bar.dart';
|
||||
import 'widgets/noo/nav/noo_sidebar.dart';
|
||||
@@ -44,6 +44,7 @@ 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';
|
||||
import 'widgets/shell/tablet_account_menu.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -135,6 +136,7 @@ class NextcloudApp extends StatelessWidget {
|
||||
return DynamicColorBuilder(
|
||||
builder: (lightDynamic, darkDynamic) {
|
||||
return MaterialApp(
|
||||
builder: (context, child) => NooPointerSelection(child: child!),
|
||||
title: 'Noo',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(
|
||||
@@ -209,7 +211,13 @@ class MainShellView extends StatefulWidget {
|
||||
State<MainShellView> createState() => _MainShellViewState();
|
||||
}
|
||||
|
||||
class _MainShellViewState extends State<MainShellView> {
|
||||
class _MainShellViewState extends State<MainShellView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _paneTransition = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 240),
|
||||
value: 1,
|
||||
);
|
||||
late AppTab _currentTab;
|
||||
final Set<AppTab> _selectionActiveTabs = {};
|
||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||
@@ -329,6 +337,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
_shareSub?.cancel();
|
||||
_pickSub?.cancel();
|
||||
_shareAccountSync?.dispose();
|
||||
_paneTransition.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -338,7 +347,6 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
final settings = context.watch<SettingsController>();
|
||||
final pick = context.watch<PickController>();
|
||||
final connectivity = context.watch<ConnectivityController>();
|
||||
final session = context.watch<SessionController>();
|
||||
final quota = context.watch<FilesController>().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.
|
||||
@@ -397,6 +405,10 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
|
||||
void selectTab(AppTab tab) {
|
||||
if (tab == _currentTab) return;
|
||||
if (NooLayout.isDesktop(context) &&
|
||||
!MediaQuery.disableAnimationsOf(context)) {
|
||||
_paneTransition.forward(from: 0);
|
||||
}
|
||||
setState(() => _currentTab = tab);
|
||||
}
|
||||
|
||||
@@ -486,16 +498,9 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
child: 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),
|
||||
),
|
||||
account: const TabletAccountMenu(),
|
||||
search: const ShellSearchLauncher(onSurface: true),
|
||||
groupItems: true,
|
||||
items: [
|
||||
for (final tab in pinnedTabs)
|
||||
NooSidebarItem(
|
||||
@@ -529,18 +534,37 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
children: [
|
||||
NooToolbar(
|
||||
title: selectedTab.label,
|
||||
search: const ShellSearchLauncher(onSurface: true),
|
||||
backgroundColor: colors.bg,
|
||||
framed: false,
|
||||
actions: [
|
||||
if (canUpload)
|
||||
NooButton(
|
||||
icon: LucideIcons.upload,
|
||||
icon: LucideIcons.plus,
|
||||
onTap: () => showCreateMenu(context),
|
||||
child: const Text('Upload'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: ColoredBox(color: colors.surface, child: tabStack),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
child: ColoredBox(
|
||||
color: colors.surface2,
|
||||
child: FadeTransition(
|
||||
opacity: Tween<double>(begin: .65, end: 1)
|
||||
.animate(
|
||||
CurvedAnimation(
|
||||
parent: _paneTransition,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
),
|
||||
child: SizedBox.expand(child: tabStack),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,11 +5,16 @@ class SavedAccount {
|
||||
final String id;
|
||||
final String serverUrl;
|
||||
final String username;
|
||||
final String? displayName;
|
||||
|
||||
String get label =>
|
||||
displayName?.trim().isNotEmpty == true ? displayName!.trim() : username;
|
||||
|
||||
const SavedAccount({
|
||||
required this.id,
|
||||
required this.serverUrl,
|
||||
required this.username,
|
||||
this.displayName,
|
||||
});
|
||||
|
||||
/// A deterministic id from the server + username, so re-adding the same
|
||||
@@ -25,11 +30,13 @@ class SavedAccount {
|
||||
'id': id,
|
||||
'serverUrl': serverUrl,
|
||||
'username': username,
|
||||
if (displayName != null) 'displayName': displayName,
|
||||
};
|
||||
|
||||
factory SavedAccount.fromJson(Map<String, dynamic> json) => SavedAccount(
|
||||
id: json['id'] as String,
|
||||
serverUrl: json['serverUrl'] as String,
|
||||
username: json['username'] as String,
|
||||
displayName: json['displayName'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -470,7 +470,10 @@ class FilesController extends ChangeNotifier
|
||||
'[FilesController] Loaded ${_items.length} items for $_currentFolderPath',
|
||||
);
|
||||
try {
|
||||
_quota = await service.fetchUserQuota();
|
||||
final quota = await service.fetchUserQuota();
|
||||
if (gen != session.sessionGeneration) return;
|
||||
_quota = quota;
|
||||
await session.cacheDisplayName(quota.userName, generation: gen);
|
||||
} catch (e) {
|
||||
debugPrint('[FilesController] Quota fetch warning: $e');
|
||||
}
|
||||
|
||||
@@ -171,6 +171,39 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
// Getters
|
||||
String get serverUrl => _serverUrl;
|
||||
String get username => _username;
|
||||
String get displayName =>
|
||||
_accounts
|
||||
.where((a) => a.id == activeAccountId)
|
||||
.map((a) => a.label)
|
||||
.firstOrNull ??
|
||||
_username;
|
||||
|
||||
Future<void> cacheDisplayName(String name, {required int generation}) async {
|
||||
final clean = name.trim();
|
||||
if (generation != _sessionGeneration || clean.isEmpty) return;
|
||||
final id = activeAccountId;
|
||||
if (id == null ||
|
||||
!_accounts.any((a) => a.id == id && a.displayName != clean)) {
|
||||
return;
|
||||
}
|
||||
final prefs = await prefsFuture;
|
||||
if (generation != _sessionGeneration) return;
|
||||
_accounts = [
|
||||
for (final a in _accounts)
|
||||
if (a.id == id)
|
||||
SavedAccount(
|
||||
id: a.id,
|
||||
serverUrl: a.serverUrl,
|
||||
username: a.username,
|
||||
displayName: clean,
|
||||
)
|
||||
else
|
||||
a,
|
||||
];
|
||||
await accountStore.saveAccounts(prefs, _accounts);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get isLoggedIn => _isLoggedIn;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isRestoringSession => _isRestoringSession;
|
||||
@@ -560,6 +593,8 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
if (index == -1) return;
|
||||
final wasActive = accountId == _activeAccountId;
|
||||
|
||||
await SyncService.removeAccountData(accountId);
|
||||
|
||||
_accounts = [..._accounts]..removeAt(index);
|
||||
final prefs = await prefsFuture;
|
||||
await accountStore.saveAccounts(prefs, _accounts);
|
||||
@@ -568,8 +603,6 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
await prefs.remove(accountStore.accountPrefKey(accountId, key));
|
||||
}
|
||||
await accountStore.setSignedOut(prefs, accountId, false);
|
||||
// Its background sync job holds that account's credentials - stop it.
|
||||
unawaited(SyncService.cancelAccount(accountId));
|
||||
|
||||
if (!wasActive) {
|
||||
notifyListeners();
|
||||
|
||||
@@ -12,6 +12,7 @@ class ShareAccountEntry {
|
||||
final String id;
|
||||
final String serverUrl;
|
||||
final String username;
|
||||
final String? displayName;
|
||||
final String password;
|
||||
|
||||
/// The account's Files "hidden files" filter: `hide`, `only` or `include`.
|
||||
@@ -24,6 +25,7 @@ class ShareAccountEntry {
|
||||
required this.id,
|
||||
required this.serverUrl,
|
||||
required this.username,
|
||||
this.displayName,
|
||||
required this.password,
|
||||
required this.hiddenFilter,
|
||||
required this.storageScope,
|
||||
@@ -60,7 +62,7 @@ class ShareAccountService {
|
||||
'username': a.username,
|
||||
'authHeader': basicAuth(a.username, a.password),
|
||||
'displayName':
|
||||
'${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
|
||||
'${a.displayName?.trim().isNotEmpty == true ? a.displayName!.trim() : a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
|
||||
'hiddenFilter': a.hiddenFilter,
|
||||
'storageScope': a.storageScope,
|
||||
},
|
||||
@@ -119,6 +121,7 @@ class ShareAccountService {
|
||||
id: account.id,
|
||||
serverUrl: account.serverUrl,
|
||||
username: account.username,
|
||||
displayName: account.label,
|
||||
password: password,
|
||||
hiddenFilter: filter.name,
|
||||
storageScope: scope.name,
|
||||
|
||||
@@ -132,6 +132,14 @@ class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the removed account's mirror and native sync state after stopping
|
||||
/// its runs. Await before forgetting the account so cleanup failures can retry.
|
||||
static Future<void> removeAccountData(String accountId) async {
|
||||
await invokeIfAvailable(_channel, 'removeAccountData', {
|
||||
'accountId': accountId,
|
||||
});
|
||||
}
|
||||
|
||||
static String _basicAuth(SavedAccount account, String password) =>
|
||||
'Basic ${base64Encode(utf8.encode('${account.username}:$password'))}';
|
||||
|
||||
@@ -143,7 +151,10 @@ class SyncService {
|
||||
/// background job is being turned off - the account's paths are still
|
||||
/// synced by hand ("Sync now"), and a conflict notification's actions
|
||||
/// still need its credentials.
|
||||
static Future<void> cancelAccount(String accountId, {bool forget = true}) async {
|
||||
static Future<void> cancelAccount(
|
||||
String accountId, {
|
||||
bool forget = true,
|
||||
}) async {
|
||||
await invokeIfAvailable(_channel, 'cancel', {
|
||||
'accountId': accountId,
|
||||
'forget': forget,
|
||||
|
||||
+120
-33
@@ -5,9 +5,11 @@ import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||
import '../widgets/noo/nav/noo_top_bar.dart';
|
||||
import '../widgets/noo/nav/noo_toolbar.dart';
|
||||
import '../widgets/noo/nav/noo_sidebar.dart';
|
||||
import '../widgets/noo/noo_layout.dart';
|
||||
import '../widgets/settings/settings_account_card.dart';
|
||||
import '../widgets/settings/settings_accounts.dart';
|
||||
import '../widgets/settings/settings_about.dart';
|
||||
import '../widgets/settings/settings_action_bar.dart';
|
||||
import '../widgets/settings/settings_appearance.dart';
|
||||
import '../widgets/settings/settings_file_sync.dart';
|
||||
@@ -15,35 +17,20 @@ import '../widgets/settings/settings_files_cache.dart';
|
||||
import '../widgets/settings/settings_security.dart';
|
||||
import '../widgets/settings/settings_swipe.dart';
|
||||
import '../widgets/settings/settings_tabs.dart';
|
||||
import '../widgets/settings/settings_section.dart';
|
||||
|
||||
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
|
||||
/// order: account card, accounts, security, file sync, files cache,
|
||||
/// appearance, tabs, action bar, swipe on a file).
|
||||
///
|
||||
/// Every layout (including wide tablet, in a centered 640px column) is
|
||||
/// a two-level menu, the way native iOS/Android Settings apps
|
||||
/// work: [SettingsAccountCard] (the account summary, not a settings picker)
|
||||
/// stays pinned at the top of a single top-level list ([_MobileMenu]), and
|
||||
/// every other section becomes one tappable [NooSettingsRow] - icon, title,
|
||||
/// chevron - in a [NooGroupedList] below it. Tapping a row pushes a new
|
||||
/// [_SettingsCategoryScreen] with its own [NooTopBar]/[NooTopBarBack],
|
||||
/// containing just that section's content full-screen. This replaces an
|
||||
/// earlier design where every section rendered inline in one long
|
||||
/// collapsible-sections column (`_MobileList`, since removed) - and before
|
||||
/// that, a trailing jump rail (an even earlier version) that scrolled that
|
||||
/// *same* page to an anchor. Both were rejected: the jump rail added a
|
||||
/// second, redundant way to navigate on top of plain scrolling without
|
||||
/// shortening the page, and the collapsible-sections column still left a
|
||||
/// long page to scroll past even collapsed. A genuinely separate pushed
|
||||
/// screen per category removes the scroll-depth problem outright, so
|
||||
/// neither a jump rail nor per-section collapsing is needed any more - see
|
||||
/// [SettingsSection]'s doc comment for how that reflects in its mobile
|
||||
/// layout. See each `widgets/settings/*.dart` file for a section's own
|
||||
/// content and any setting that had to be slotted in or grouped under
|
||||
/// "Advanced appearance".
|
||||
class AccountView extends StatelessWidget {
|
||||
/// Settings uses pushed category screens on phones and an inline category
|
||||
/// sidebar with a rounded content pane on tablets.
|
||||
class AccountView extends StatefulWidget {
|
||||
const AccountView({super.key});
|
||||
|
||||
@override
|
||||
State<AccountView> createState() => _AccountViewState();
|
||||
}
|
||||
|
||||
class _AccountViewState extends State<AccountView> {
|
||||
int selected = 0;
|
||||
|
||||
static final _categories = <_SettingsCategory>[
|
||||
_SettingsCategory(
|
||||
title: 'Accounts',
|
||||
@@ -85,6 +72,11 @@ class AccountView extends StatelessWidget {
|
||||
icon: LucideIcons.chevronsLeftRight,
|
||||
builder: (_) => const SettingsSwipeSection(),
|
||||
),
|
||||
_SettingsCategory(
|
||||
title: 'About',
|
||||
icon: LucideIcons.info,
|
||||
builder: (_) => const SettingsAboutSection(),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -92,6 +84,101 @@ class AccountView extends StatelessWidget {
|
||||
final colors = context.nooColors;
|
||||
final desktop = NooLayout.isDesktop(context);
|
||||
|
||||
if (desktop) {
|
||||
final category = _categories[selected];
|
||||
final duration = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 260);
|
||||
return Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
body: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
NooSidebar(
|
||||
windowControls: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
tooltip: 'Back',
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Settings',
|
||||
style: NooText.title.copyWith(color: colors.fg1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
account: SettingsAccountCard(
|
||||
compact: true,
|
||||
onManage: () => setState(() => selected = 0),
|
||||
),
|
||||
groupItems: true,
|
||||
items: [
|
||||
for (var index = 0; index < _categories.length; index++)
|
||||
if (_categories[index].title != 'Swipe on a file')
|
||||
NooSidebarItem(
|
||||
icon: _categories[index].icon,
|
||||
label: _categories[index].title,
|
||||
selected: selected == index,
|
||||
onTap: () => setState(() => selected = index),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
NooToolbar(
|
||||
title: category.title,
|
||||
backgroundColor: colors.bg,
|
||||
framed: false,
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
|
||||
child: Material(
|
||||
color: colors.surface2,
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedSwitcher(
|
||||
duration: duration,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) =>
|
||||
FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, .025),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
key: ValueKey(category.title),
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
SettingsCategoryHeading(
|
||||
title: category.title,
|
||||
child: category.builder(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
appBar: desktop
|
||||
@@ -184,12 +271,7 @@ class _MobileMenu extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pushed, single-category Settings screen: [NooTopBar] titled with the
|
||||
/// category, a back button, and just that section's own content - the
|
||||
/// pushed half of [AccountView]'s mobile menu-then-screen pattern. Always
|
||||
/// built in a mobile-width context (desktop never opens this screen; it
|
||||
/// shows every section inline in its own grid instead), so the section
|
||||
/// widgets inside render their normal mobile layout unchanged.
|
||||
/// A pushed category screen used by phone navigation.
|
||||
class _SettingsCategoryScreen extends StatelessWidget {
|
||||
final _SettingsCategory category;
|
||||
const _SettingsCategoryScreen({required this.category});
|
||||
@@ -223,7 +305,12 @@ class _SettingsCategoryScreen extends StatelessWidget {
|
||||
NooSpace.xxl,
|
||||
),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: [category.builder(context)],
|
||||
children: [
|
||||
SettingsCategoryHeading(
|
||||
title: category.title,
|
||||
child: category.builder(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -41,7 +41,6 @@ class ActivityView extends StatelessWidget {
|
||||
final files = context.watch<FilesController>();
|
||||
final session = context.watch<SessionController>();
|
||||
final activities = files.activities;
|
||||
final isDesktop = NooLayout.isDesktop(context);
|
||||
final groups = groupByCalendarDay<NextcloudActivity>(
|
||||
activities,
|
||||
(a) => a.timestamp,
|
||||
@@ -116,22 +115,12 @@ class ActivityView extends StatelessWidget {
|
||||
);
|
||||
|
||||
return ColoredBox(
|
||||
color: colors.bg,
|
||||
color: NooLayout.contentBackground(context),
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
onRefresh: () => context.read<FilesController>().refreshData(),
|
||||
// "Desktop limits it to 760px wide" (DESIGN_SYSTEM.md §4) - centered
|
||||
// rather than left-aligned, so a wide window doesn't stretch the
|
||||
// feed's short sentences edge to edge.
|
||||
child: isDesktop
|
||||
? Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: scrollView,
|
||||
),
|
||||
)
|
||||
: scrollView,
|
||||
child: scrollView,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -400,6 +400,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
favorite: item.isFavorite,
|
||||
iosStyle: NooLayout.iosStyle(context),
|
||||
selected: isSelected,
|
||||
onSelectionToggle: () => _toggleSelection(item),
|
||||
thumbnail: _thumbnailFor(context, item, extent: NooSizes.rowMobile),
|
||||
onTap: () {
|
||||
if (_isSelecting) {
|
||||
@@ -434,6 +435,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
col3: item.isFolder ? null : formatBytes(item.size),
|
||||
favorite: item.isFavorite,
|
||||
selected: isSelected,
|
||||
onSelectionToggle: () => _toggleSelection(item),
|
||||
thumbnail: _thumbnailFor(
|
||||
context,
|
||||
item,
|
||||
@@ -477,6 +479,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
thumbnailHeight: thumbnailHeight,
|
||||
thumbnail: _thumbnailFor(context, item, extent: thumbnailHeight),
|
||||
selected: isSelected,
|
||||
onSelectionToggle: () => _toggleSelection(item),
|
||||
verticalOverflowIcon: !NooLayout.iosStyle(context),
|
||||
onTap: () {
|
||||
if (_isSelecting) {
|
||||
@@ -600,14 +603,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
vertical: NooSpace.xs,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
childAspectRatio: isDesktop ? 0.92 : 0.85,
|
||||
),
|
||||
gridDelegate: NooLayout.fileGridDelegate(context),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
return _buildGridCard(
|
||||
context,
|
||||
@@ -680,7 +676,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
? _buildSelectionBar(context, selectedItems)
|
||||
: null,
|
||||
child: ColoredBox(
|
||||
color: colors.bg,
|
||||
color: NooLayout.contentBackground(context),
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
|
||||
@@ -729,9 +729,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
default:
|
||||
if (_isPdf) {
|
||||
return MediaPdfPreview(
|
||||
topInset: _controlsVisible
|
||||
? MediaQuery.paddingOf(context).top + 60
|
||||
: 0,
|
||||
topInset: MediaQuery.paddingOf(context).top + 60,
|
||||
item: widget.item,
|
||||
session: session,
|
||||
localPathResolver: widget.localPathResolver,
|
||||
|
||||
+24
-17
@@ -745,14 +745,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
),
|
||||
gridDelegate: NooLayout.fileGridDelegate(context),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
@@ -971,7 +964,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
? _buildSelectionBar(context, selectedItems)
|
||||
: null,
|
||||
child: ColoredBox(
|
||||
color: colors.bg,
|
||||
color: NooLayout.contentBackground(context),
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
@@ -1061,14 +1054,16 @@ class _FilesViewState extends State<FilesView> {
|
||||
tone: hasConflicts
|
||||
? NooSummaryCardTone.danger
|
||||
: NooSummaryCardTone.normal,
|
||||
action: NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.compact,
|
||||
icon: LucideIcons.refreshCw,
|
||||
disabled: sync.isSyncingNow,
|
||||
onTap: () => sync.syncOnPull(),
|
||||
child: const Text('Sync now'),
|
||||
),
|
||||
action: sync.syncEverything || sync.syncedPaths.isNotEmpty
|
||||
? NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.compact,
|
||||
icon: LucideIcons.refreshCw,
|
||||
disabled: sync.isSyncingNow,
|
||||
onTap: () => sync.syncOnPull(),
|
||||
child: const Text('Sync now'),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1360,6 +1355,10 @@ class _FilesViewState extends State<FilesView> {
|
||||
favorite: item.isFavorite,
|
||||
iosStyle: NooLayout.iosStyle(context),
|
||||
selected: isSelected,
|
||||
onSelectionToggle:
|
||||
_offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||
? null
|
||||
: () => _toggleSelection(item),
|
||||
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.row),
|
||||
onTap: () => _handleItemTap(context, item, picking: picking),
|
||||
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||
@@ -1447,6 +1446,10 @@ class _FilesViewState extends State<FilesView> {
|
||||
statuses: _nooStatuses(status),
|
||||
favorite: item.isFavorite,
|
||||
selected: isSelected,
|
||||
onSelectionToggle:
|
||||
_offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||
? null
|
||||
: () => _toggleSelection(item),
|
||||
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop),
|
||||
onTap: () => _handleItemTap(context, item, picking: picking),
|
||||
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||
@@ -1549,6 +1552,10 @@ class _FilesViewState extends State<FilesView> {
|
||||
: null,
|
||||
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
|
||||
selected: isSelected,
|
||||
onSelectionToggle:
|
||||
_offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||
? null
|
||||
: () => _toggleSelection(item),
|
||||
onTap: () => _handleItemTap(context, item, picking: picking),
|
||||
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||
? null
|
||||
|
||||
+62
-19
@@ -37,7 +37,9 @@ class _LoginViewState extends State<LoginView> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.isAddingAccount) {
|
||||
_originalActiveAccountId = context.read<SessionController>().activeAccountId;
|
||||
_originalActiveAccountId = context
|
||||
.read<SessionController>()
|
||||
.activeAccountId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +52,17 @@ class _LoginViewState extends State<LoginView> {
|
||||
void _handleContinue() {
|
||||
final url = _urlController.text.trim();
|
||||
if (url.isEmpty) {
|
||||
setState(() => _localError = 'Please enter your Nextcloud server address');
|
||||
setState(
|
||||
() => _localError = 'Please enter your Nextcloud server address',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _localError = null);
|
||||
FocusScope.of(context).unfocus();
|
||||
context.read<SessionController>().startLoginFlow(url, addAccount: widget.isAddingAccount);
|
||||
context.read<SessionController>().startLoginFlow(
|
||||
url,
|
||||
addAccount: widget.isAddingAccount,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
|
||||
@@ -73,7 +80,7 @@ class _LoginViewState extends State<LoginView> {
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Couldn't sign in as ${account.username}"),
|
||||
content: Text("Couldn't sign in as ${account.label}"),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
@@ -97,7 +104,8 @@ class _LoginViewState extends State<LoginView> {
|
||||
final colors = context.nooColors;
|
||||
final session = context.watch<SessionController>();
|
||||
|
||||
final isAwaitingBrowser = session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
|
||||
final isAwaitingBrowser =
|
||||
session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
|
||||
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
|
||||
|
||||
// A new/refreshed account has just become active - pop back to
|
||||
@@ -117,7 +125,9 @@ class _LoginViewState extends State<LoginView> {
|
||||
// SessionController.startLoginFlow's doc comment for why. Push it the
|
||||
// moment there's a URL to show; _webViewPushed resets once that route
|
||||
// pops (cancelled or done) so a retry after cancelling pushes it again.
|
||||
if (!_webViewPushed && isAwaitingBrowser && session.pendingLoginUrl != null) {
|
||||
if (!_webViewPushed &&
|
||||
isAwaitingBrowser &&
|
||||
session.pendingLoginUrl != null) {
|
||||
_webViewPushed = true;
|
||||
final url = session.pendingLoginUrl!;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
@@ -142,7 +152,10 @@ class _LoginViewState extends State<LoginView> {
|
||||
}
|
||||
|
||||
final errorMessage =
|
||||
_localError ?? (session.loginFlowStatus == LoginFlowStatus.error ? session.errorMessage : null);
|
||||
_localError ??
|
||||
(session.loginFlowStatus == LoginFlowStatus.error
|
||||
? session.errorMessage
|
||||
: null);
|
||||
|
||||
final body = Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
@@ -167,7 +180,8 @@ class _LoginViewState extends State<LoginView> {
|
||||
// deleting it, specifically so it can be resumed
|
||||
// from here with one tap - no need to repeat
|
||||
// Login Flow v2.
|
||||
if (!widget.isAddingAccount && session.accounts.isNotEmpty) ...[
|
||||
if (!widget.isAddingAccount &&
|
||||
session.accounts.isNotEmpty) ...[
|
||||
_SavedAccountsSection(
|
||||
accounts: session.accounts,
|
||||
onSelect: (account) => _continueAsAccount(account),
|
||||
@@ -179,7 +193,10 @@ class _LoginViewState extends State<LoginView> {
|
||||
Expanded(child: Divider(color: colors.line)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text('or', style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
child: Text(
|
||||
'or',
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: colors.line)),
|
||||
],
|
||||
@@ -188,7 +205,8 @@ class _LoginViewState extends State<LoginView> {
|
||||
],
|
||||
_ServerForm(
|
||||
urlController: _urlController,
|
||||
isLoading: isInitiating || session.isLoading || isAwaitingBrowser,
|
||||
isLoading:
|
||||
isInitiating || session.isLoading || isAwaitingBrowser,
|
||||
errorMessage: errorMessage,
|
||||
onContinue: _handleContinue,
|
||||
),
|
||||
@@ -246,11 +264,19 @@ class _ServerForm extends StatelessWidget {
|
||||
// in-app, rather than the full-color launcher icon.
|
||||
child: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
|
||||
child: Image.asset('assets/icon/app_icon_monochrome.png', width: 80, height: 80),
|
||||
child: Image.asset(
|
||||
'assets/icon/app_icon_monochrome.png',
|
||||
width: 80,
|
||||
height: 80,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Noo', textAlign: TextAlign.center, style: NooText.largeTitle.copyWith(color: colors.fg1)),
|
||||
Text(
|
||||
'Noo',
|
||||
textAlign: TextAlign.center,
|
||||
style: NooText.largeTitle.copyWith(color: colors.fg1),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Connect to your self-hosted server',
|
||||
@@ -317,7 +343,11 @@ class _SavedAccountsSection extends StatelessWidget {
|
||||
final ValueChanged<SavedAccount> onSelect;
|
||||
final ValueChanged<SavedAccount> onRemove;
|
||||
|
||||
const _SavedAccountsSection({required this.accounts, required this.onSelect, required this.onRemove});
|
||||
const _SavedAccountsSection({
|
||||
required this.accounts,
|
||||
required this.onSelect,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -328,7 +358,10 @@ class _SavedAccountsSection extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text('Continue as', style: NooText.label.copyWith(color: colors.fg2)),
|
||||
child: Text(
|
||||
'Continue as',
|
||||
style: NooText.label.copyWith(color: colors.fg2),
|
||||
),
|
||||
),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
@@ -358,7 +391,11 @@ class _SavedAccountRow extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _SavedAccountRow({required this.account, required this.onTap, required this.onRemove});
|
||||
const _SavedAccountRow({
|
||||
required this.account,
|
||||
required this.onTap,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -369,18 +406,21 @@ class _SavedAccountRow extends StatelessWidget {
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
NooAvatar(initials: accountInitial(account.username), size: 36),
|
||||
NooAvatar(initials: accountInitial(account.label), size: 36),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(
|
||||
account.username,
|
||||
account.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.bodyL.copyWith(
|
||||
@@ -404,7 +444,10 @@ class _SavedAccountRow extends StatelessWidget {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface2,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(LucideIcons.x, size: 16, color: colors.fg2),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -283,14 +283,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: desktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: desktop ? 16 : 10,
|
||||
mainAxisSpacing: desktop ? 16 : 10,
|
||||
),
|
||||
gridDelegate: NooLayout.fileGridDelegate(context),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildGridCard(folders[index]),
|
||||
childCount: folders.length,
|
||||
|
||||
@@ -355,13 +355,7 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
),
|
||||
NooPhotoGrid(
|
||||
itemCount: group.items.length,
|
||||
columns: isDesktop
|
||||
? NooPhotoGrid.desktopColumns
|
||||
: NooLayout.gridColumns(
|
||||
context,
|
||||
phone: NooPhotoGrid.mobileColumns,
|
||||
minTile: 130,
|
||||
),
|
||||
minTileWidth: 150,
|
||||
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
|
||||
@@ -393,7 +387,7 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
? _buildSelectionBar(context, pick, selectedItems)
|
||||
: null,
|
||||
child: ColoredBox(
|
||||
color: colors.bg,
|
||||
color: NooLayout.contentBackground(context),
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
@@ -653,6 +647,9 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
|
||||
return NooPhotoTile(
|
||||
selected: isSelected,
|
||||
onSelectionToggle: pick.isPicking && !pick.pickRequest!.allowMultiple
|
||||
? null
|
||||
: () => _toggleSelection(photo),
|
||||
selectionMode: _isSelecting,
|
||||
// No real duration source (no video-metadata extraction anywhere in
|
||||
// the app) - an empty string still marks it as a video and shows the
|
||||
|
||||
@@ -83,7 +83,7 @@ class _RecentViewState extends State<RecentView> {
|
||||
];
|
||||
|
||||
return ColoredBox(
|
||||
color: colors.bg,
|
||||
color: NooLayout.contentBackground(context),
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
|
||||
+24
-13
@@ -123,18 +123,24 @@ class _SearchViewState extends State<SearchView> {
|
||||
return Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
appBar: desktop
|
||||
? NooToolbar(
|
||||
titleWidget: _buildField(onSurface: true),
|
||||
actions: [
|
||||
IconTheme.merge(
|
||||
data: IconThemeData(color: colors.fg1, size: 24),
|
||||
child: NooTopBarButton(
|
||||
icon: LucideIcons.x,
|
||||
tooltip: 'Close',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
? PreferredSize(
|
||||
preferredSize: const Size.fromHeight(NooToolbar.outerHeight),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: NooToolbar(
|
||||
titleWidget: _buildField(onSurface: true),
|
||||
actions: [
|
||||
IconTheme.merge(
|
||||
data: IconThemeData(color: colors.fg1, size: 24),
|
||||
child: NooTopBarButton(
|
||||
icon: LucideIcons.x,
|
||||
tooltip: 'Close',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: SafeArea(
|
||||
@@ -223,7 +229,9 @@ class _SearchViewState extends State<SearchView> {
|
||||
title: 'No results found',
|
||||
);
|
||||
}
|
||||
return desktop ? _buildDesktopResults(context) : _buildMobileResults(context);
|
||||
return desktop
|
||||
? _buildDesktopResults(context)
|
||||
: _buildMobileResults(context);
|
||||
}
|
||||
|
||||
Widget _buildMessage(
|
||||
@@ -304,7 +312,10 @@ class _SearchViewState extends State<SearchView> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
|
||||
const NooFileTableHeader(
|
||||
col2Label: 'Modified',
|
||||
col3Label: 'Location',
|
||||
),
|
||||
for (final item in _results)
|
||||
NooFileTableRow(
|
||||
kind: NooFileKind.from(
|
||||
|
||||
@@ -208,14 +208,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: desktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: desktop ? 16 : 10,
|
||||
mainAxisSpacing: desktop ? 16 : 10,
|
||||
),
|
||||
gridDelegate: NooLayout.fileGridDelegate(context),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildGridCard(context, folders[index]),
|
||||
childCount: folders.length,
|
||||
|
||||
@@ -129,7 +129,7 @@ class _SharesViewState extends State<SharesView> {
|
||||
];
|
||||
|
||||
return ColoredBox(
|
||||
color: colors.bg,
|
||||
color: NooLayout.contentBackground(context),
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
|
||||
+25
-91
@@ -9,7 +9,6 @@ import '../widgets/noo/core/noo_button.dart';
|
||||
import '../widgets/noo/files/noo_file_kind.dart';
|
||||
import '../widgets/noo/files/noo_file_row.dart';
|
||||
import '../widgets/noo/files/noo_file_table.dart';
|
||||
import '../widgets/noo/files/noo_file_tile.dart';
|
||||
import '../widgets/noo/lists/noo_banner.dart';
|
||||
import '../widgets/noo/noo_layout.dart';
|
||||
import '../widgets/tabs/tab_location.dart';
|
||||
@@ -98,7 +97,7 @@ class _TrashViewState extends State<TrashView> {
|
||||
];
|
||||
|
||||
return ColoredBox(
|
||||
color: colors.bg,
|
||||
color: NooLayout.contentBackground(context),
|
||||
child: RefreshIndicator(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.surface,
|
||||
@@ -335,17 +334,7 @@ class _TrashViewState extends State<TrashView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop trash row: like `NooFileTableRow`, but its 120px last column
|
||||
/// holds a tonal "Restore" button plus a danger-tonal "Delete" button
|
||||
/// (permanent delete) instead of status icons - `NooFileTableRow` has no
|
||||
/// slot for screen-specific actions there. A labelled pill, not a bare
|
||||
/// overflow icon: it's the row's only destructive action, not a menu of
|
||||
/// several, and desktop already prefers labelled buttons to bare icons
|
||||
/// (see `NooSelectionBar`'s own desktop actions). Column widths
|
||||
/// (180/160/120) are matched by hand to `NooFileTableHeader`'s (private in
|
||||
/// `noo_file_table.dart`) so this still lines up under it; promoting an
|
||||
/// optional `actions` slot onto `NooFileTableRow` would let this fold back
|
||||
/// into the shared component.
|
||||
/// Trash actions use the same responsive columns as the other file tables.
|
||||
class _TrashDesktopRow extends StatelessWidget {
|
||||
final NooFileKind kind;
|
||||
final String name;
|
||||
@@ -364,83 +353,28 @@ class _TrashDesktopRow extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
|
||||
|
||||
return SizedBox(
|
||||
height: NooSizes.rowDesktop,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
NooFileTile(kind: kind, size: NooFileTileSize.desktop),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.body.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: NooSpace.md),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: Text(
|
||||
col2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: metaStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Text(
|
||||
col3,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: metaStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.xs,
|
||||
onTap: onRestore,
|
||||
child: const Text('Restore'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
NooButton(
|
||||
variant: NooButtonVariant.danger,
|
||||
size: NooButtonSize.xs,
|
||||
onTap: onDelete,
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Widget build(BuildContext context) => NooFileTableRow(
|
||||
kind: kind,
|
||||
name: name,
|
||||
col2: col2,
|
||||
col3: col3,
|
||||
actions: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.xs,
|
||||
onTap: onRestore,
|
||||
child: const Text('Restore'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
const SizedBox(width: 4),
|
||||
NooButton(
|
||||
variant: NooButtonVariant.danger,
|
||||
size: NooButtonSize.xs,
|
||||
onTap: onDelete,
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ class AppDrawer extends StatelessWidget {
|
||||
return NooDrawer(
|
||||
account: NooDrawerAccount(
|
||||
avatar: NooAvatar(
|
||||
initials: accountInitial(session.username),
|
||||
initials: accountInitial(session.displayName),
|
||||
current: true,
|
||||
size: 48,
|
||||
),
|
||||
name: session.username,
|
||||
name: session.displayName,
|
||||
subtitle: serverHost(session.serverUrl),
|
||||
onTap: closeAndOpenSettings,
|
||||
onSwitchAccount: () => showAccountSwitcher(context),
|
||||
|
||||
@@ -109,7 +109,7 @@ class AvatarMenuCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
session.username,
|
||||
session.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.bodyL.copyWith(
|
||||
@@ -140,7 +140,7 @@ class AvatarMenuCard extends StatelessWidget {
|
||||
onTap: onClose,
|
||||
child: NooAvatar(
|
||||
key: headerAvatarKey,
|
||||
initials: accountInitial(session.username),
|
||||
initials: accountInitial(session.displayName),
|
||||
current: true,
|
||||
size: 32,
|
||||
),
|
||||
@@ -159,7 +159,7 @@ class AvatarMenuCard extends StatelessWidget {
|
||||
for (final account in session.accounts)
|
||||
if (account.id != session.activeAccountId)
|
||||
_OtherAccountRow(
|
||||
name: account.username,
|
||||
name: account.label,
|
||||
host: serverHost(account.serverUrl),
|
||||
onTap: () {
|
||||
onClose();
|
||||
@@ -667,7 +667,7 @@ class _AvatarNavigationHostState extends State<AvatarNavigationHost>
|
||||
child: GestureDetector(
|
||||
onTap: close,
|
||||
child: NooAvatar(
|
||||
initials: accountInitial(session.username),
|
||||
initials: accountInitial(session.displayName),
|
||||
current: true,
|
||||
size: 32,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Tracks pointer input without assuming that a large screen has a mouse.
|
||||
class NooPointerSelection extends StatefulWidget {
|
||||
final Widget child;
|
||||
const NooPointerSelection({super.key, required this.child});
|
||||
|
||||
static bool available(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_PointerScope>()?.available ??
|
||||
false;
|
||||
|
||||
@override
|
||||
State<NooPointerSelection> createState() => _NooPointerSelectionState();
|
||||
}
|
||||
|
||||
class _NooPointerSelectionState extends State<NooPointerSelection> {
|
||||
bool trackpad = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.mouseTracker.addListener(_refresh);
|
||||
GestureBinding.instance.pointerRouter.addGlobalRoute(_pointer);
|
||||
}
|
||||
|
||||
void _pointer(PointerEvent event) {
|
||||
if (event.kind == PointerDeviceKind.trackpad && !trackpad) {
|
||||
trackpad = true;
|
||||
_refresh();
|
||||
} else if (event.kind == PointerDeviceKind.touch && trackpad) {
|
||||
trackpad = false;
|
||||
_refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
WidgetsBinding.instance.scheduleFrame();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.mouseTracker.removeListener(_refresh);
|
||||
GestureBinding.instance.pointerRouter.removeGlobalRoute(_pointer);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _PointerScope(
|
||||
available:
|
||||
trackpad || WidgetsBinding.instance.mouseTracker.mouseIsConnected,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
class _PointerScope extends InheritedWidget {
|
||||
final bool available;
|
||||
const _PointerScope({required this.available, required super.child});
|
||||
@override
|
||||
bool updateShouldNotify(_PointerScope oldWidget) =>
|
||||
available != oldWidget.available;
|
||||
}
|
||||
|
||||
class NooPointerCheckbox extends StatelessWidget {
|
||||
final bool selected;
|
||||
final VoidCallback? onToggle;
|
||||
const NooPointerCheckbox({super.key, required this.selected, this.onToggle});
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
onToggle == null || !NooPointerSelection.available(context)
|
||||
? const SizedBox.shrink()
|
||||
: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Checkbox(
|
||||
value: selected,
|
||||
onChanged: (_) => onToggle!(),
|
||||
semanticLabel: selected ? 'Deselect item' : 'Select item',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/noo_pointer_selection.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
import 'noo_file_kind.dart';
|
||||
@@ -41,6 +42,7 @@ class NooFileRow extends StatelessWidget {
|
||||
/// `ellipsis` on iOS, `ellipsis-vertical` everywhere else.
|
||||
final bool iosStyle;
|
||||
final bool selected;
|
||||
final VoidCallback? onSelectionToggle;
|
||||
|
||||
/// Real image/video thumbnail for the tile; see [NooFileTile.thumbnail].
|
||||
final Widget? thumbnail;
|
||||
@@ -62,6 +64,7 @@ class NooFileRow extends StatelessWidget {
|
||||
this.trailing,
|
||||
this.iosStyle = false,
|
||||
this.selected = false,
|
||||
this.onSelectionToggle,
|
||||
this.thumbnail,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
@@ -93,6 +96,10 @@ class NooFileRow extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
NooPointerCheckbox(
|
||||
selected: selected,
|
||||
onToggle: onSelectionToggle,
|
||||
),
|
||||
NooFileTile(kind: kind, thumbnail: thumbnail),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
Expanded(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/noo_pointer_selection.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
import 'noo_file_kind.dart';
|
||||
@@ -6,9 +7,8 @@ import 'noo_file_row.dart';
|
||||
import 'noo_file_tile.dart';
|
||||
import 'noo_status_icon.dart';
|
||||
|
||||
/// Fixed widths of the desktop file table's grid
|
||||
/// (`minmax(0,1fr) 180px 160px 120px`, `DESIGN_SYSTEM.md` 2 "File row").
|
||||
/// The first column takes the remaining width.
|
||||
/// Maximum metadata widths for wide file tables. Compact panes collapse
|
||||
/// the date column and reserve the remaining width for the filename.
|
||||
const double _col2Width = 180;
|
||||
const double _col3Width = 160;
|
||||
const double _col4Width = 120;
|
||||
@@ -36,13 +36,33 @@ class _TableColumns extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _tableInset),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: name),
|
||||
SizedBox(width: _col2Width, child: col2),
|
||||
SizedBox(width: _col3Width, child: col3),
|
||||
SizedBox(width: _col4Width, child: col4),
|
||||
],
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final compact = width < 600;
|
||||
final statusWidth = width < 420 ? 80.0 : _col4Width;
|
||||
final metadataWidth = compact
|
||||
? 72.0
|
||||
: (width * .18).clamp(90.0, _col3Width);
|
||||
final dateWidth = compact
|
||||
? 0.0
|
||||
: (width * .22).clamp(110.0, _col2Width);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: name),
|
||||
if (!compact) SizedBox(width: dateWidth, child: col2),
|
||||
if (width >= 420) SizedBox(width: metadataWidth, child: col3),
|
||||
SizedBox(
|
||||
width: statusWidth,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: col4,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -150,7 +170,11 @@ class NooFileTableRow extends StatelessWidget {
|
||||
final List<NooSyncStatus> statuses;
|
||||
final bool favorite;
|
||||
final bool selected;
|
||||
final VoidCallback? onSelectionToggle;
|
||||
final Widget? thumbnail;
|
||||
|
||||
/// Optional last-column actions, laid out using the shared column widths.
|
||||
final Widget? actions;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDoubleTap;
|
||||
final VoidCallback? onSecondaryTap;
|
||||
@@ -168,7 +192,9 @@ class NooFileTableRow extends StatelessWidget {
|
||||
this.statuses = const [],
|
||||
this.favorite = false,
|
||||
this.selected = false,
|
||||
this.onSelectionToggle,
|
||||
this.thumbnail,
|
||||
this.actions,
|
||||
this.onTap,
|
||||
this.onDoubleTap,
|
||||
this.onSecondaryTap,
|
||||
@@ -201,6 +227,10 @@ class NooFileTableRow extends StatelessWidget {
|
||||
child: _TableColumns(
|
||||
name: Row(
|
||||
children: [
|
||||
NooPointerCheckbox(
|
||||
selected: selected,
|
||||
onToggle: onSelectionToggle,
|
||||
),
|
||||
NooFileTile(
|
||||
kind: kind,
|
||||
size: NooFileTileSize.desktop,
|
||||
@@ -224,20 +254,27 @@ class NooFileTableRow extends StatelessWidget {
|
||||
),
|
||||
col2: dataCell(col2),
|
||||
col3: dataCell(col3),
|
||||
col4: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
for (final s in statuses) ...[
|
||||
NooStatusIcon(s),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
if (favorite) ...[
|
||||
Icon(LucideIcons.star, size: 14, color: colors.accentText),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
if (onMore != null) NooOverflowButton(size: 32, onTap: onMore),
|
||||
],
|
||||
),
|
||||
col4:
|
||||
actions ??
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
for (final s in statuses) ...[
|
||||
NooStatusIcon(s),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
if (favorite) ...[
|
||||
Icon(
|
||||
LucideIcons.star,
|
||||
size: 14,
|
||||
color: colors.accentText,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
if (onMore != null)
|
||||
NooOverflowButton(size: 32, onTap: onMore),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'noo_grouped_list.dart';
|
||||
import 'noo_settings_row.dart';
|
||||
|
||||
/// Bulk actions shown inline before the rest collapse behind "More" - a
|
||||
/// fixed, non-scrolling width on every platform (design canvas
|
||||
/// capped, non-scrolling row adapted to the available pane width (design canvas
|
||||
/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action
|
||||
/// bar"). Mobile is tighter (icons share the bar with the close circle and
|
||||
/// count on a ~360dp phone); desktop's labelled pills have a wider toolbar
|
||||
@@ -34,9 +34,9 @@ const int _kDesktopInlineActions = 4;
|
||||
/// presentation differ:
|
||||
/// - Mobile: the first [_kMobileInlineActions] actions as plain 20px
|
||||
/// accent-text icons, no fill.
|
||||
/// - Desktop: the first [_kDesktopInlineActions] as labelled tonal pills -
|
||||
/// - Wide panes: up to [_kDesktopInlineActions] as labelled tonal pills -
|
||||
/// danger-soft/danger for the one labelled "Delete", accent-soft/
|
||||
/// accent-text otherwise - since desktop has the room for labels.
|
||||
/// accent-text otherwise. Narrow tablet panes use the icon presentation.
|
||||
///
|
||||
/// Any actions beyond that inline count sit behind a trailing "More"
|
||||
/// button that opens the same grouped-list sheet a file row's own overflow
|
||||
@@ -62,12 +62,43 @@ class NooSelectionBar extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, constraints) => _buildBar(context, constraints.maxWidth),
|
||||
);
|
||||
|
||||
Widget _buildBar(BuildContext context, double width) {
|
||||
final colors = context.nooColors;
|
||||
final gutter = isDesktop ? NooSpace.xl : NooSpace.sm;
|
||||
final inlineCount = isDesktop
|
||||
final labelled =
|
||||
isDesktop &&
|
||||
width >= 700 &&
|
||||
MediaQuery.textScalerOf(context).scale(14) <= 18;
|
||||
var inlineCount = labelled
|
||||
? _kDesktopInlineActions
|
||||
: _kMobileInlineActions;
|
||||
: ((width - gutter * 2 - 12 - 140 - 32) / 40).floor().clamp(
|
||||
0,
|
||||
_kMobileInlineActions,
|
||||
);
|
||||
if (labelled) {
|
||||
var used = 0.0;
|
||||
final budget = width - gutter * 2 - 12 - 140 - 85;
|
||||
inlineCount = 0;
|
||||
for (final action in actions.take(_kDesktopInlineActions)) {
|
||||
final text = TextPainter(
|
||||
text: TextSpan(
|
||||
text: action.label,
|
||||
style: NooText.buttonSm.copyWith(fontSize: 13),
|
||||
),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
)..layout();
|
||||
final pillWidth = text.width + 51;
|
||||
text.dispose();
|
||||
if (used + pillWidth > budget) break;
|
||||
used += pillWidth;
|
||||
inlineCount++;
|
||||
}
|
||||
}
|
||||
final inlineActions = actions.length > inlineCount
|
||||
? actions.sublist(0, inlineCount)
|
||||
: actions;
|
||||
@@ -111,33 +142,33 @@ class NooSelectionBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: NooSpace.xs),
|
||||
Text(
|
||||
'$count selected',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.cardTitle.copyWith(
|
||||
fontSize: 17,
|
||||
color: colors.fg1,
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$count selected',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.cardTitle.copyWith(
|
||||
fontSize: 17,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: NooSpace.xs),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: isDesktop
|
||||
? _desktopActions(
|
||||
context,
|
||||
colors,
|
||||
inlineActions,
|
||||
overflowActions,
|
||||
)
|
||||
: _mobileActions(
|
||||
context,
|
||||
colors,
|
||||
inlineActions,
|
||||
overflowActions,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: labelled
|
||||
? _desktopActions(
|
||||
context,
|
||||
colors,
|
||||
inlineActions,
|
||||
overflowActions,
|
||||
)
|
||||
: _mobileActions(
|
||||
context,
|
||||
colors,
|
||||
inlineActions,
|
||||
overflowActions,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/noo_pointer_selection.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
/// Grid-view file card: a thumbnail area (file-type soft colour + 32px
|
||||
/// icon, or a real [thumbnail]) above the name, an overflow button and a
|
||||
/// meta line (`DESIGN_SYSTEM.md` section 2, "Grid card"). Mobile lays these
|
||||
/// out 2-up with a 10px gap, desktop 5-up with a 16px gap - the parent grid
|
||||
/// out with 10px phone / 16px tablet gaps and responsive columns - the parent grid
|
||||
/// owns that, this card just fills its cell.
|
||||
///
|
||||
/// The placeholder colours/icon are plain parameters rather than a file
|
||||
@@ -30,6 +31,7 @@ class NooGridCard extends StatelessWidget {
|
||||
final double thumbnailHeight;
|
||||
|
||||
final bool selected;
|
||||
final VoidCallback? onSelectionToggle;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@@ -49,6 +51,7 @@ class NooGridCard extends StatelessWidget {
|
||||
this.thumbnail,
|
||||
this.thumbnailHeight = 104,
|
||||
this.selected = false,
|
||||
this.onSelectionToggle,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onMore,
|
||||
@@ -88,7 +91,19 @@ class NooGridCard extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
thumb,
|
||||
Stack(
|
||||
children: [
|
||||
thumb,
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
child: NooPointerCheckbox(
|
||||
selected: selected,
|
||||
onToggle: onSelectionToggle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
|
||||
@@ -40,7 +40,10 @@ class NooPhotoGroupHeader extends StatelessWidget {
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.groupHeading.copyWith(height: 1, color: colors.fg1),
|
||||
style: NooText.groupHeading.copyWith(
|
||||
height: 1,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
@@ -67,6 +70,7 @@ class NooPhotoGrid extends StatelessWidget {
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
final int columns;
|
||||
final double gap;
|
||||
final double? minTileWidth;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final bool _sliver;
|
||||
|
||||
@@ -77,6 +81,7 @@ class NooPhotoGrid extends StatelessWidget {
|
||||
required this.itemBuilder,
|
||||
this.columns = 3,
|
||||
this.gap = 2,
|
||||
this.minTileWidth,
|
||||
this.padding = EdgeInsets.zero,
|
||||
}) : _sliver = true;
|
||||
|
||||
@@ -87,6 +92,7 @@ class NooPhotoGrid extends StatelessWidget {
|
||||
required this.itemBuilder,
|
||||
this.columns = 3,
|
||||
this.gap = 2,
|
||||
this.minTileWidth,
|
||||
this.padding = EdgeInsets.zero,
|
||||
}) : _sliver = false;
|
||||
|
||||
@@ -96,12 +102,17 @@ class NooPhotoGrid extends StatelessWidget {
|
||||
static const desktopColumns = 8;
|
||||
static const desktopGap = 4.0;
|
||||
|
||||
SliverGridDelegate get _delegate =>
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
);
|
||||
SliverGridDelegate get _delegate => minTileWidth != null
|
||||
? SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: minTileWidth!,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
)
|
||||
: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/noo_pointer_selection.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
@@ -25,6 +26,7 @@ class NooPhotoTile extends StatelessWidget {
|
||||
final String? videoDuration;
|
||||
|
||||
final bool selected;
|
||||
final VoidCallback? onSelectionToggle;
|
||||
|
||||
/// Shows the selection check circle even when not [selected] (i.e. the
|
||||
/// grid is in multi-select mode).
|
||||
@@ -39,6 +41,7 @@ class NooPhotoTile extends StatelessWidget {
|
||||
this.placeholderColor,
|
||||
this.videoDuration,
|
||||
this.selected = false,
|
||||
this.onSelectionToggle,
|
||||
this.selectionMode = false,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
@@ -63,7 +66,9 @@ class NooPhotoTile extends StatelessWidget {
|
||||
children: [
|
||||
// Selected tiles inset so an accent-soft frame shows around
|
||||
// the photo, instead of covering it with a tint.
|
||||
ColoredBox(color: selected ? colors.accentSoft : Colors.transparent),
|
||||
ColoredBox(
|
||||
color: selected ? colors.accentSoft : Colors.transparent,
|
||||
),
|
||||
AnimatedPadding(
|
||||
duration: NooMotion.fast,
|
||||
curve: NooMotion.ease,
|
||||
@@ -87,11 +92,21 @@ class NooPhotoTile extends StatelessWidget {
|
||||
bottom: 6,
|
||||
child: _VideoBadge(duration: videoDuration!),
|
||||
),
|
||||
if (selected || selectionMode)
|
||||
if (selected ||
|
||||
selectionMode ||
|
||||
(onSelectionToggle != null &&
|
||||
NooPointerSelection.available(context)))
|
||||
Positioned(
|
||||
left: 6,
|
||||
top: 6,
|
||||
child: _SelectCheck(selected: selected),
|
||||
child:
|
||||
NooPointerSelection.available(context) &&
|
||||
onSelectionToggle != null
|
||||
? NooPointerCheckbox(
|
||||
selected: selected,
|
||||
onToggle: onSelectionToggle,
|
||||
)
|
||||
: _SelectCheck(selected: selected),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -152,9 +167,7 @@ class _SelectCheck extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? colors.accent : _videoBadgeColor,
|
||||
border: selected
|
||||
? null
|
||||
: Border.all(color: Colors.white, width: 1.5),
|
||||
border: selected ? null : Border.all(color: Colors.white, width: 1.5),
|
||||
),
|
||||
child: selected
|
||||
? const Icon(LucideIcons.check, size: 14, color: Colors.white)
|
||||
|
||||
@@ -22,6 +22,8 @@ import '../core/noo_progress_bar.dart';
|
||||
class NooSidebar extends StatelessWidget {
|
||||
final Widget? windowControls;
|
||||
final Widget? account;
|
||||
final Widget? search;
|
||||
final bool groupItems;
|
||||
final List<Widget> items;
|
||||
final Widget? storage;
|
||||
final Widget? settings;
|
||||
@@ -34,6 +36,8 @@ class NooSidebar extends StatelessWidget {
|
||||
super.key,
|
||||
this.windowControls,
|
||||
this.account,
|
||||
this.search,
|
||||
this.groupItems = false,
|
||||
this.items = const [],
|
||||
this.storage,
|
||||
this.settings,
|
||||
@@ -46,7 +50,10 @@ class NooSidebar extends StatelessWidget {
|
||||
const padding = EdgeInsets.fromLTRB(12, 12, 12, 16);
|
||||
|
||||
return Container(
|
||||
width: NooSizes.sidebarWidth,
|
||||
width: (MediaQuery.sizeOf(context).width * .3).clamp(
|
||||
200.0,
|
||||
NooSizes.sidebarWidth,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.bg,
|
||||
border: showDivider
|
||||
@@ -76,7 +83,26 @@ class NooSidebar extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: account,
|
||||
),
|
||||
...items,
|
||||
if (search != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: search,
|
||||
),
|
||||
if (groupItems)
|
||||
Material(
|
||||
color: colors.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
side: BorderSide(color: colors.line),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: items,
|
||||
),
|
||||
)
|
||||
else
|
||||
...items,
|
||||
const Spacer(),
|
||||
?storage,
|
||||
?settings,
|
||||
|
||||
@@ -31,6 +31,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
/// Defaults to `surface` - the toolbar belongs to the main pane.
|
||||
final Color? backgroundColor;
|
||||
final bool framed;
|
||||
|
||||
const NooToolbar({
|
||||
super.key,
|
||||
@@ -42,6 +43,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
|
||||
this.onBack,
|
||||
this.onForward,
|
||||
this.backgroundColor,
|
||||
this.framed = true,
|
||||
}) : assert(
|
||||
title == null || titleWidget == null,
|
||||
'Pass either title or titleWidget, not both.',
|
||||
@@ -67,7 +69,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
side: BorderSide(color: colors.line),
|
||||
side: framed ? BorderSide(color: colors.line) : BorderSide.none,
|
||||
),
|
||||
child: Container(
|
||||
height: NooSizes.toolbar,
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'nav/noo_nav_style.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
|
||||
/// The one place screens decide mobile vs desktop layout and iOS vs Android
|
||||
/// chrome (DESIGN_SYSTEM.md 3), so every screen switches at the same point.
|
||||
class NooLayout {
|
||||
const NooLayout._();
|
||||
|
||||
/// Window width at which the shell swaps the bottom bar + drawer for the
|
||||
/// desktop sidebar + toolbar, and screens switch to desktop recipes
|
||||
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
|
||||
static const double desktopBreakpoint = 900;
|
||||
|
||||
/// Shortest window side that counts as a tablet-class window (the usual
|
||||
/// 600dp rule). A phone in landscape is wide but short, so it stays on the
|
||||
/// mobile layout.
|
||||
@@ -18,10 +14,13 @@ class NooLayout {
|
||||
|
||||
static bool isDesktop(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return size.width >= desktopBreakpoint &&
|
||||
size.shortestSide >= tabletShortestSide;
|
||||
return size.shortestSide >= tabletShortestSide;
|
||||
}
|
||||
|
||||
/// A quieter pane tone keeps cards distinct from their container.
|
||||
static Color contentBackground(BuildContext context) =>
|
||||
isDesktop(context) ? context.nooColors.surface2 : context.nooColors.bg;
|
||||
|
||||
static NooNavStyle navStyle(BuildContext context) =>
|
||||
NooNavStyle.fromPlatform(Theme.of(context).platform);
|
||||
|
||||
@@ -33,9 +32,23 @@ class NooLayout {
|
||||
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
|
||||
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12;
|
||||
|
||||
/// Grid column count for [minTile]-wide tiles, never fewer than [phone]
|
||||
/// (so phones keep their usual count while wide non-sidebar windows, e.g. a
|
||||
/// tablet in portrait, get more instead of a few huge tiles).
|
||||
/// File cards use the available pane width and a content-sized height.
|
||||
static SliverGridDelegate fileGridDelegate(BuildContext context) {
|
||||
final tablet = isDesktop(context);
|
||||
final scale = MediaQuery.textScalerOf(context);
|
||||
return SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 220,
|
||||
mainAxisExtent:
|
||||
(tablet ? 118 : 104) +
|
||||
26 +
|
||||
scale.scale(14) * 1.2 +
|
||||
scale.scale(12) * 1.3,
|
||||
crossAxisSpacing: tablet ? 16 : 10,
|
||||
mainAxisSpacing: tablet ? 16 : 10,
|
||||
);
|
||||
}
|
||||
|
||||
/// Grid column count for [minTile]-wide tiles, never fewer than [phone].
|
||||
static int gridColumns(
|
||||
BuildContext context, {
|
||||
required int phone,
|
||||
|
||||
@@ -30,7 +30,7 @@ class ProfileAvatarButton extends StatelessWidget {
|
||||
HapticFeedback.selectionClick();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Switched to ${target.username}'),
|
||||
content: Text('Switched to ${target.label}'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
@@ -39,8 +39,8 @@ class ProfileAvatarButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<SessionController>();
|
||||
final initial = provider.username.isNotEmpty
|
||||
? provider.username[0].toUpperCase()
|
||||
final initial = provider.displayName.isNotEmpty
|
||||
? provider.displayName[0].toUpperCase()
|
||||
: '?';
|
||||
|
||||
return Padding(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
class SettingsAboutSection extends StatefulWidget {
|
||||
const SettingsAboutSection({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsAboutSection> createState() => _SettingsAboutSectionState();
|
||||
}
|
||||
|
||||
class _SettingsAboutSectionState extends State<SettingsAboutSection> {
|
||||
late final Future<PackageInfo> _info = PackageInfo.fromPlatform();
|
||||
|
||||
Future<void> _openLink(String url) async {
|
||||
try {
|
||||
if (await launchUrl(
|
||||
Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not open link. Please try again.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<PackageInfo>(
|
||||
future: _info,
|
||||
builder: (context, snapshot) {
|
||||
final info = snapshot.data;
|
||||
final pending = snapshot.hasError ? 'Unavailable' : 'Loading…';
|
||||
return SettingsSection(
|
||||
title: 'About',
|
||||
children: [
|
||||
const NooSettingsRow(
|
||||
label: Text('Noo'),
|
||||
subtitle: Text('An independent Nextcloud client'),
|
||||
),
|
||||
NooSettingsRow(
|
||||
label: const Text('Version'),
|
||||
trailing: Text(info?.version ?? pending),
|
||||
),
|
||||
NooSettingsRow(
|
||||
label: const Text('Build number'),
|
||||
trailing: Text(info?.buildNumber ?? pending),
|
||||
),
|
||||
NooSettingsRow(
|
||||
label: const Text('App identifier'),
|
||||
subtitle: Text(info?.packageName ?? pending),
|
||||
),
|
||||
for (final link in {
|
||||
'Website': 'https://noo.ayushya.dev',
|
||||
'GitHub': 'https://github.com/ayushyamitabh/noo',
|
||||
'Privacy policy': 'https://noo.ayushya.dev/privacy',
|
||||
}.entries)
|
||||
NooSettingsRow(
|
||||
label: Text(link.key),
|
||||
value: '',
|
||||
onTap: () => _openLink(link.value),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../noo/core/noo_chip.dart';
|
||||
import '../noo/core/noo_progress_bar.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../shell/shell_common.dart';
|
||||
import '../shell/tablet_account_menu.dart';
|
||||
import 'settings_dialogs.dart';
|
||||
|
||||
/// Settings section 1: the account card (DESIGN_SYSTEM.md 4 - "NooAvatar
|
||||
@@ -18,7 +19,9 @@ import 'settings_dialogs.dart';
|
||||
/// the pre-rework card and have no other home, so they stay here as a
|
||||
/// button row under the storage meter.
|
||||
class SettingsAccountCard extends StatelessWidget {
|
||||
const SettingsAccountCard({super.key});
|
||||
final bool compact;
|
||||
final VoidCallback? onManage;
|
||||
const SettingsAccountCard({super.key, this.compact = false, this.onManage});
|
||||
|
||||
Future<void> _refresh(BuildContext context, FilesController files) async {
|
||||
await files.refreshData();
|
||||
@@ -48,7 +51,7 @@ class SettingsAccountCard extends StatelessWidget {
|
||||
context,
|
||||
session,
|
||||
accountId: id,
|
||||
username: session.username,
|
||||
username: session.displayName,
|
||||
host: serverHost(session.serverUrl),
|
||||
isActive: true,
|
||||
);
|
||||
@@ -60,52 +63,67 @@ class SettingsAccountCard extends StatelessWidget {
|
||||
final session = context.watch<SessionController>();
|
||||
final files = context.watch<FilesController>();
|
||||
final quota = files.quota;
|
||||
final name = quota?.userName ?? session.username;
|
||||
final name = session.displayName;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(NooSpace.lg),
|
||||
padding: EdgeInsets.all(compact ? 10 : NooSpace.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
border: NooLayout.isDesktop(context) ? Border.all(color: colors.line) : null,
|
||||
border: NooLayout.isDesktop(context)
|
||||
? Border.all(color: colors.line)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
NooAvatar(initials: accountInitial(name), current: true, size: 56),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.cardTitle.copyWith(color: colors.fg1),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
serverHost(session.serverUrl),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
if (compact)
|
||||
TabletAccountMenu(framed: false, onManage: onManage)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
NooAvatar(
|
||||
initials: accountInitial(name),
|
||||
current: true,
|
||||
size: 56,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.cardTitle.copyWith(color: colors.fg1),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
serverHost(session.serverUrl),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: NooSpace.lg),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Storage', style: NooText.label.copyWith(color: colors.fg2)),
|
||||
Text(
|
||||
quota != null ? quotaDetail(quota) : 'Loading…',
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
quota != null ? quotaDetail(quota) : 'Loading…',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -116,7 +134,9 @@ class SettingsAccountCard extends StatelessWidget {
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [for (final group in quota.groups) NooChip(child: Text(group))],
|
||||
children: [
|
||||
for (final group in quota.groups) NooChip(child: Text(group)),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: NooSpace.md),
|
||||
|
||||
@@ -34,7 +34,7 @@ class SettingsAccountsSection extends StatelessWidget {
|
||||
context,
|
||||
session,
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
username: account.label,
|
||||
host: serverHost(account.serverUrl),
|
||||
isActive: account.id == session.activeAccountId,
|
||||
),
|
||||
@@ -45,7 +45,9 @@ class SettingsAccountsSection extends StatelessWidget {
|
||||
label: const Text('Add account'),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginView(isAddingAccount: true)),
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LoginView(isAddingAccount: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -79,11 +81,18 @@ class _SavedAccountRow extends StatelessWidget {
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
NooAvatar(initials: accountInitial(account.username), current: isActive, size: 36),
|
||||
NooAvatar(
|
||||
initials: accountInitial(account.label),
|
||||
current: isActive,
|
||||
size: 36,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -108,15 +117,27 @@ class _SavedAccountRow extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isActive) Icon(LucideIcons.checkCircle, size: 20, color: colors.accentText),
|
||||
if (isActive)
|
||||
Icon(
|
||||
LucideIcons.checkCircle,
|
||||
size: 20,
|
||||
color: colors.accentText,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
|
||||
child: Icon(LucideIcons.trash2, size: 16, color: colors.danger),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface2,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 16,
|
||||
color: colors.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../noo/lists/noo_settings_row.dart';
|
||||
import '../noo/lists/noo_grouped_list.dart';
|
||||
import '../noo/core/noo_segmented_control.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Settings section: the priority order bulk actions (favorite, share,
|
||||
/// download, ...) show in on the Files/Photos/Favorites multi-select
|
||||
@@ -77,10 +78,11 @@ class SettingsActionBarSection extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Action bar',
|
||||
style: NooText.cardTitle.copyWith(color: colors.fg1),
|
||||
),
|
||||
if (SettingsCategoryHeading.shows(context, 'Action bar'))
|
||||
Text(
|
||||
'Action bar',
|
||||
style: NooText.cardTitle.copyWith(color: colors.fg1),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
const SizedBox(height: NooSpace.md),
|
||||
|
||||
@@ -43,22 +43,23 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
final tablet = NooLayout.isDesktop(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsSection(
|
||||
title: 'Appearance',
|
||||
notice:
|
||||
'Bottom bar style, menu style and search in bottom bar have no effect on wide tablets, which use a sidebar.',
|
||||
children: [
|
||||
_ThemeRow(settings: settings),
|
||||
_BottomBarStyleRow(settings: settings),
|
||||
if (!tablet) _BottomBarStyleRow(settings: settings),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.glassWater,
|
||||
label: const Text('Frosted glass'),
|
||||
subtitle: const Text(
|
||||
'Blurred bottom navigation, avatar menus and media viewer panels',
|
||||
subtitle: Text(
|
||||
tablet
|
||||
? 'Blurred media viewer panels'
|
||||
: 'Blurred bottom navigation, avatar menus and media viewer panels',
|
||||
),
|
||||
trailing: NooToggle(
|
||||
checked: settings.bottomBarFrosted,
|
||||
@@ -66,8 +67,8 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
|
||||
_NavMenuStyleRow(settings: settings),
|
||||
if (settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
|
||||
if (!tablet) _NavMenuStyleRow(settings: settings),
|
||||
if (!tablet && settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.userRound,
|
||||
label: const Text('Avatar position'),
|
||||
@@ -84,27 +85,29 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.search,
|
||||
label: const Text('Search in bottom bar'),
|
||||
subtitle: const Text(
|
||||
'Adds Search to the bottom bar and removes it from the top '
|
||||
'bar - leaves room for one fewer regular tab',
|
||||
if (!tablet)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.search,
|
||||
label: const Text('Search in bottom bar'),
|
||||
subtitle: const Text(
|
||||
'Adds Search to the bottom bar and removes it from the top '
|
||||
'bar - leaves room for one fewer regular tab',
|
||||
),
|
||||
trailing: NooToggle(
|
||||
checked: settings.searchInBottomBar,
|
||||
onChanged: settings.setSearchInBottomBar,
|
||||
),
|
||||
),
|
||||
trailing: NooToggle(
|
||||
checked: settings.searchInBottomBar,
|
||||
onChanged: settings.setSearchInBottomBar,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: NooSpace.xl),
|
||||
SettingsSection(
|
||||
title: 'Upload button',
|
||||
notice:
|
||||
'Auto shows the label on Files and Photos and shrinks to an icon on other tabs. Mini and Expanded stay the same on every tab. Has no effect on wide tablets.',
|
||||
children: [_FabStyleRow(settings: settings)],
|
||||
),
|
||||
if (!tablet) const SizedBox(height: NooSpace.xl),
|
||||
if (!tablet)
|
||||
SettingsSection(
|
||||
title: 'Upload button',
|
||||
notice:
|
||||
'Auto shows the label on Files and Photos and shrinks to an icon on other tabs. Mini and Expanded stay the same on every tab.',
|
||||
children: [_FabStyleRow(settings: settings)],
|
||||
),
|
||||
const SizedBox(height: NooSpace.xl),
|
||||
SettingsSection(
|
||||
title: 'Advanced appearance',
|
||||
|
||||
@@ -22,8 +22,8 @@ Future<void> confirmRemoveAccount(
|
||||
}) async {
|
||||
final hasOtherAccounts = session.accounts.length > 1;
|
||||
final message = isActive && hasOtherAccounts
|
||||
? 'Remove $username ($host)? Another saved account will become active.'
|
||||
: 'Remove $username ($host)? You can add it again later.';
|
||||
? 'Remove $username ($host)? Its synced files will be removed from this device. Another saved account will become active.'
|
||||
: 'Remove $username ($host)? Its synced files will be removed from this device. You can add it again later.';
|
||||
|
||||
var confirmed = false;
|
||||
void cancel() => Navigator.pop(context);
|
||||
@@ -80,7 +80,20 @@ Future<void> confirmRemoveAccount(
|
||||
if (!confirmed) return;
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await session.removeAccount(accountId);
|
||||
try {
|
||||
await session.removeAccount(accountId);
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Could not remove local account data. Please try again.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Only when this was the last saved account does isLoggedIn drop to false
|
||||
// and main.dart swap the root route to LoginView underneath - pop back to
|
||||
// it then, rather than leaving Settings stranded on top. Removing a
|
||||
|
||||
@@ -6,6 +6,24 @@ import '../noo/noo_layout.dart';
|
||||
import '../noo/overlays/noo_dialog.dart';
|
||||
import '../noo/overlays/noo_sheet.dart';
|
||||
|
||||
/// Category heading already supplied by the tablet toolbar.
|
||||
class SettingsCategoryHeading extends InheritedWidget {
|
||||
final String title;
|
||||
const SettingsCategoryHeading({
|
||||
super.key,
|
||||
required this.title,
|
||||
required super.child,
|
||||
});
|
||||
static bool shows(BuildContext context, String title) =>
|
||||
context
|
||||
.dependOnInheritedWidgetOfExactType<SettingsCategoryHeading>()
|
||||
?.title !=
|
||||
title;
|
||||
@override
|
||||
bool updateShouldNotify(SettingsCategoryHeading oldWidget) =>
|
||||
title != oldWidget.title;
|
||||
}
|
||||
|
||||
/// One block of Settings (DESIGN_SYSTEM.md 4's 9-part order): a
|
||||
/// [NooGroupedList] on mobile (label above a radius-20 card), or a titled,
|
||||
/// bordered radius-20 card holding a flat row group on desktop
|
||||
@@ -34,7 +52,7 @@ class SettingsSection extends StatelessWidget {
|
||||
final colors = context.nooColors;
|
||||
if (!NooLayout.isDesktop(context)) {
|
||||
return NooGroupedList(
|
||||
label: title,
|
||||
label: SettingsCategoryHeading.shows(context, title) ? title : null,
|
||||
notice: notice != null ? NooInfoNote(message: notice!) : null,
|
||||
footer: subtitle != null ? Text(subtitle!) : null,
|
||||
children: children,
|
||||
@@ -50,7 +68,8 @@ class SettingsSection extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||
if (SettingsCategoryHeading.shows(context, title))
|
||||
Text(title, style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
|
||||
@@ -47,7 +47,6 @@ class SettingsSwipeSection extends StatelessWidget {
|
||||
return SettingsSection(
|
||||
title: 'Swipe on a file',
|
||||
subtitle: 'Choose what swiping a file left or right does in list view',
|
||||
notice: 'Swipe actions have no effect on wide tablets.',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.chevronsRight,
|
||||
|
||||
@@ -78,17 +78,18 @@ class SettingsTabsSection extends StatelessWidget {
|
||||
final desktop = NooLayout.isDesktop(context);
|
||||
|
||||
final optionRows = [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.arrowUpDown,
|
||||
label: const Text('Tap tab to scroll to top'),
|
||||
subtitle: const Text(
|
||||
'Tapping the current bottom bar tab scrolls its list back to the top',
|
||||
if (!desktop)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.arrowUpDown,
|
||||
label: const Text('Tap tab to scroll to top'),
|
||||
subtitle: const Text(
|
||||
'Tapping the current bottom bar tab scrolls its list back to the top',
|
||||
),
|
||||
trailing: NooToggle(
|
||||
checked: settings.tapTabToScrollTop,
|
||||
onChanged: settings.setTapTabToScrollTop,
|
||||
),
|
||||
),
|
||||
trailing: NooToggle(
|
||||
checked: settings.tapTabToScrollTop,
|
||||
onChanged: settings.setTapTabToScrollTop,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.star,
|
||||
label: const Text('Default tab'),
|
||||
@@ -136,7 +137,8 @@ class SettingsTabsSection extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Tabs', style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||
if (SettingsCategoryHeading.shows(context, 'Tabs'))
|
||||
Text('Tabs', style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
const SizedBox(height: NooSpace.md),
|
||||
|
||||
@@ -510,10 +510,10 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
const SizedBox(height: NooSpace.xs),
|
||||
NooPersonAccessRow(
|
||||
avatar: NooAvatar(
|
||||
initials: _initial(session.username),
|
||||
initials: _initial(session.displayName),
|
||||
current: true,
|
||||
),
|
||||
name: session.username,
|
||||
name: session.displayName,
|
||||
owner: true,
|
||||
),
|
||||
for (final share in peopleShares)
|
||||
|
||||
@@ -88,7 +88,7 @@ void showAccountSwitcher(BuildContext context) {
|
||||
children: [
|
||||
for (final account in session.accounts)
|
||||
_AccountSwitchRow(
|
||||
name: account.username,
|
||||
name: account.label,
|
||||
host: serverHost(account.serverUrl),
|
||||
active: account.id == session.activeAccountId,
|
||||
onTap: account.id == session.activeAccountId
|
||||
@@ -278,7 +278,7 @@ class ShellAvatarButton extends StatelessWidget {
|
||||
radius: hitBox / 2,
|
||||
child: Center(
|
||||
child: NooAvatar(
|
||||
initials: accountInitial(session.username),
|
||||
initials: accountInitial(session.displayName),
|
||||
current: true,
|
||||
size: 32,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../views/login_view.dart';
|
||||
import '../noo/core/noo_avatar.dart';
|
||||
import '../noo/core/noo_button.dart';
|
||||
import 'shell_common.dart';
|
||||
|
||||
class TabletAccountMenu extends StatefulWidget {
|
||||
final bool framed;
|
||||
final VoidCallback? onManage;
|
||||
const TabletAccountMenu({super.key, this.framed = true, this.onManage});
|
||||
|
||||
@override
|
||||
State<TabletAccountMenu> createState() => _TabletAccountMenuState();
|
||||
}
|
||||
|
||||
class _TabletAccountMenuState extends State<TabletAccountMenu> {
|
||||
bool expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
final colors = context.nooColors;
|
||||
final duration = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 340);
|
||||
Widget accountRow(
|
||||
String name, {
|
||||
bool current = false,
|
||||
VoidCallback? onTap,
|
||||
}) => InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
NooAvatar(
|
||||
initials: accountInitial(name),
|
||||
current: current,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.body.copyWith(fontSize: 14, color: colors.fg1),
|
||||
),
|
||||
),
|
||||
if (current)
|
||||
AnimatedRotation(
|
||||
turns: expanded ? .5 : 0,
|
||||
duration: duration,
|
||||
curve: Curves.easeInOutCubicEmphasized,
|
||||
child: Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 18,
|
||||
color: colors.accentText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return Material(
|
||||
color: widget.framed ? colors.surface : Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
side: widget.framed ? BorderSide(color: colors.line) : BorderSide.none,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
accountRow(
|
||||
session.displayName,
|
||||
current: true,
|
||||
onTap: () => setState(() => expanded = !expanded),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: duration,
|
||||
curve: Curves.easeInOutCubicEmphasized,
|
||||
alignment: Alignment.topCenter,
|
||||
child: expanded
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final account in session.accounts)
|
||||
if (account.id != session.activeAccountId)
|
||||
accountRow(
|
||||
account.label,
|
||||
onTap: () async {
|
||||
await session.switchAccount(account.id);
|
||||
if (mounted) setState(() => expanded = false);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 10),
|
||||
child: Column(
|
||||
spacing: 6,
|
||||
children: [
|
||||
NooButton(
|
||||
size: NooButtonSize.compact,
|
||||
fullWidth: true,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const LoginView(isAddingAccount: true),
|
||||
),
|
||||
),
|
||||
child: const Text('Add Account'),
|
||||
),
|
||||
NooButton(
|
||||
size: NooButtonSize.compact,
|
||||
fullWidth: true,
|
||||
variant: NooButtonVariant.secondary,
|
||||
onTap:
|
||||
widget.onManage ??
|
||||
() => openSettings(context),
|
||||
child: const Text('Manage Accounts'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/noo_layout.dart';
|
||||
|
||||
/// Wraps [child] as a sliver that pins to the top of the scroll view when
|
||||
/// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away
|
||||
@@ -30,20 +30,11 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
double shrinkOffset,
|
||||
bool overlapsContent,
|
||||
) {
|
||||
// `context.nooColors.bg`, not `Theme.of(context).colorScheme.surface` -
|
||||
// the latter is Flutter's own Material 3 scheme, seeded from the user's
|
||||
// accent color choice (see AppTheme.light/dark), so it carried a faint
|
||||
// hue of whatever accent is picked, and didn't match the plain
|
||||
// `colors.bg` every one of these screens' own `ColoredBox` background
|
||||
// uses. `bg`, not `surface`, so the sort/filter chips and List/Grid
|
||||
// toggle riding on top of this (each already `colors.surface`-filled)
|
||||
// still pop against it, the same as they do everywhere else in the app
|
||||
// - matching `surface` here would make this pinned header the one place
|
||||
// they'd flatten into their background instead. `surfaceTintColor:
|
||||
// Colors.transparent` guards against the same M3 elevation-tint
|
||||
// behavior even if this ever gets a non-zero elevation.
|
||||
// Match the pane so pinned controls preserve its rounded silhouette.
|
||||
return Material(
|
||||
color: floating ? Colors.transparent : context.nooColors.bg,
|
||||
color: floating
|
||||
? Colors.transparent
|
||||
: NooLayout.contentBackground(context),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
child: child,
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ class MediaPdfPreview extends StatefulWidget {
|
||||
/// `FileViewerScreen.localPathResolver`'s doc comment.
|
||||
final Future<String?> Function(NextcloudItem item)? localPathResolver;
|
||||
|
||||
/// Space reserved above the page for an overlaid top bar (0 when hidden).
|
||||
/// Stable space reserved above the page for the overlaid top bar.
|
||||
final double topInset;
|
||||
|
||||
const MediaPdfPreview({
|
||||
|
||||
Reference in New Issue
Block a user