Release 1.0.3: refine tablet layouts, pointer selection and account sync cleanup
This commit is contained in:
@@ -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