Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4611bc2142 | ||
|
|
0b7df833f6 |
@@ -275,7 +275,9 @@ bare system prompt gives no context on its own.
|
||||
|
||||
`MainShellView` builds its chrome from the Noo nav kit
|
||||
(`widgets/noo/nav/`) and switches between two layouts on
|
||||
`NooLayout.isDesktop`:
|
||||
`NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
|
||||
phone in landscape keeps the mobile layout and only tablet-class windows get
|
||||
the sidebar):
|
||||
|
||||
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||
for *every* tab (previously only Files had shell-level top chrome, with
|
||||
|
||||
@@ -803,3 +803,11 @@ Flutter's own default to already be high enough.
|
||||
rather than relying on this async path throwing naturally.
|
||||
|
||||
- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`).
|
||||
|
||||
## Changing a share's permissions
|
||||
|
||||
`NextcloudService.updateSharePermissions` does `PUT
|
||||
/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}` with a `permissions`
|
||||
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
||||
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
|
||||
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
|
||||
|
||||
@@ -80,10 +80,10 @@ Catalog:
|
||||
| Folder | Components |
|
||||
|---|---|
|
||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||||
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooSummaryCard`, `NooSelectionBar` |
|
||||
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
|
||||
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
|
||||
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
||||
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` |
|
||||
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` (floating rounded card with margin; `NooToolbar.outerHeight` for `appBar` sizing) |
|
||||
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
|
||||
|
||||
Gotchas:
|
||||
@@ -198,6 +198,27 @@ blocks are noted where they matter:
|
||||
bars and video transport controls (its other former user, the floating
|
||||
bottom nav bar, is gone - see below). Reuse this for any new floating
|
||||
overlay instead of building a new blur/shadow combo.
|
||||
- Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
|
||||
Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
|
||||
`main.dart` - auto collapses off Files/Photos, mini always icon-only,
|
||||
expanded always labelled. Phone/Android layout only.
|
||||
- PDF viewer (`media_pdf_preview.dart`): pdfx's InteractiveViewer won't zoom
|
||||
out past `viewport.height / doc.height`, so for short documents (<= 3
|
||||
pages, measured up front) the full-size `PdfViewPinch` is wrapped in a
|
||||
`MediaQuery` with extra bottom padding (pdfx's child is a `SafeArea`) so
|
||||
the boundary height reaches the viewport and fit-width stays reachable
|
||||
after pinch-zooming, without shrinking the canvas. `topInset` (status bar
|
||||
+ top bar height, 0 when the controls are hidden) animates the viewport
|
||||
below the overlaid top bar.
|
||||
- `NooGroupedList.notice`: optional widget (e.g. `NooInfoNote`) rendered
|
||||
under the label, above the card; `SettingsSection` uses it for its
|
||||
`notice` on phones.
|
||||
- [`MediaDetailsPanel`](../../lib/widgets/viewer/media_details_panel.dart) —
|
||||
the media viewer's bottom bar as a draggable panel (mobile/phone only;
|
||||
tablet-class still uses the `DetailsSheet` dialog). Dragging the bar, the
|
||||
Details button, or swiping up on swipeable media (raw `Listener` in
|
||||
`file_viewer_screen.dart`) grows it upward so the action row rides on top
|
||||
of the details; tapping the media collapses it.
|
||||
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
||||
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
||||
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
||||
@@ -232,7 +253,11 @@ blocks are noted where they matter:
|
||||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
||||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
||||
the tooltip/semantics still say "Accounts" for a button that no longer
|
||||
opens the account switcher. `showAvatarMenu` is this app's first use of
|
||||
opens the account switcher. In `avatarMenu` mode `main.dart` also sets
|
||||
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open
|
||||
the drawer. The menu header has a chevron that expands an account section
|
||||
(other saved accounts to switch to, "Add Account", "Manage Accounts")
|
||||
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of
|
||||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
||||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
||||
not a modal flow) - there's no existing anchored-popup primitive here
|
||||
@@ -336,3 +361,17 @@ blocks are noted where they matter:
|
||||
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
|
||||
|
||||
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
|
||||
|
||||
## Desktop/tablet layout notes
|
||||
|
||||
- The desktop branch in `main.dart` is wrapped in `SafeArea` so tablets keep
|
||||
system bars clear of the sidebar; the sidebar has no divider between pinned
|
||||
and hidden tabs.
|
||||
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
|
||||
pushed category screens render the desktop card layout.
|
||||
- Settings that don't apply in wide-tablet (Appearance nav options,
|
||||
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
|
||||
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
|
||||
Photos) take their column count from `NooLayout.gridColumns` (min tile
|
||||
width, never below the phone count), so a tablet in portrait gets more
|
||||
columns rather than a few huge tiles.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Redesigned Noo interface, multi-account support, app locks, offline sync, appearance customization, and a markdown viewer and editor.
|
||||
Improved tablet layout: sidebar navigation, floating title bars, and adaptive grids. Long-press selection and Settings submenus now work on tablets.
|
||||
+73
-62
@@ -444,70 +444,73 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
if (isDesktop) {
|
||||
scaffold = Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
body: Row(
|
||||
children: [
|
||||
NooSidebar(
|
||||
account: NooSidebarAccount(
|
||||
avatar: NooAvatar(
|
||||
initials: accountInitial(session.username),
|
||||
current: true,
|
||||
size: 32,
|
||||
// Tablets keep the system bars, so inset for them (desktop's
|
||||
// insets are zero).
|
||||
body: SafeArea(
|
||||
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),
|
||||
),
|
||||
name: session.username,
|
||||
subtitle: serverHost(session.serverUrl),
|
||||
onTap: () => showAccountSwitcher(context),
|
||||
),
|
||||
items: [
|
||||
for (final tab in pinnedTabs)
|
||||
NooSidebarItem(
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
count: trashBadge(tab),
|
||||
selected: tab == selectedTab,
|
||||
onTap: () => selectTab(tab),
|
||||
),
|
||||
if (hiddenTabs.isNotEmpty) const NooSidebarDivider(),
|
||||
for (final tab in hiddenTabs)
|
||||
NooSidebarItem(
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
count: trashBadge(tab),
|
||||
selected: tab == selectedTab,
|
||||
onTap: () => selectTab(tab),
|
||||
),
|
||||
],
|
||||
storage: NooSidebarStorage(
|
||||
value: quotaFraction(quota),
|
||||
detail: quotaDetail(quota),
|
||||
),
|
||||
settings: NooSidebarItem(
|
||||
icon: LucideIcons.settings,
|
||||
label: 'Settings',
|
||||
onTap: () => openSettings(context),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
NooToolbar(
|
||||
title: selectedTab.label,
|
||||
search: const ShellSearchLauncher(onSurface: true),
|
||||
actions: [
|
||||
if (canUpload)
|
||||
NooButton(
|
||||
icon: LucideIcons.upload,
|
||||
onTap: () => showCreateMenu(context),
|
||||
child: const Text('Upload'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: ColoredBox(color: colors.surface, child: tabStack),
|
||||
),
|
||||
items: [
|
||||
for (final tab in pinnedTabs)
|
||||
NooSidebarItem(
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
count: trashBadge(tab),
|
||||
selected: tab == selectedTab,
|
||||
onTap: () => selectTab(tab),
|
||||
),
|
||||
for (final tab in hiddenTabs)
|
||||
NooSidebarItem(
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
count: trashBadge(tab),
|
||||
selected: tab == selectedTab,
|
||||
onTap: () => selectTab(tab),
|
||||
),
|
||||
],
|
||||
storage: NooSidebarStorage(
|
||||
value: quotaFraction(quota),
|
||||
detail: quotaDetail(quota),
|
||||
),
|
||||
settings: NooSidebarItem(
|
||||
icon: LucideIcons.settings,
|
||||
label: 'Settings',
|
||||
onTap: () => openSettings(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
NooToolbar(
|
||||
title: selectedTab.label,
|
||||
search: const ShellSearchLauncher(onSurface: true),
|
||||
actions: [
|
||||
if (canUpload)
|
||||
NooButton(
|
||||
icon: LucideIcons.upload,
|
||||
onTap: () => showCreateMenu(context),
|
||||
child: const Text('Upload'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: ColoredBox(color: colors.surface, child: tabStack),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -517,6 +520,10 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
drawerScrimColor: colors.scrim,
|
||||
// The top bar itself no longer lives here - see `tabStack` above.
|
||||
drawer: pickRequest == null ? const AppDrawer() : null,
|
||||
// The avatar menu replaces the drawer, so the edge swipe must not
|
||||
// open it either.
|
||||
drawerEnableOpenDragGesture:
|
||||
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||
// Floating needs the body to draw behind the bar's own transparent
|
||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||
// short of it like attached does.
|
||||
@@ -529,7 +536,11 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
floatingActionButton:
|
||||
pickRequest == null && navStyle == NooNavStyle.android
|
||||
? NooFab(
|
||||
collapsed: !canUpload,
|
||||
collapsed: switch (settings.fabStyle) {
|
||||
FabStyle.auto => !canUpload,
|
||||
FabStyle.mini => true,
|
||||
FabStyle.expanded => false,
|
||||
},
|
||||
barStyle: bottomBarStyle,
|
||||
onTap: () => showCreateMenu(context),
|
||||
)
|
||||
|
||||
@@ -39,4 +39,20 @@ class NextcloudShare {
|
||||
});
|
||||
|
||||
bool get isFolder => itemType == NextcloudItemType.folder;
|
||||
|
||||
NextcloudShare withPermissions(int permissions) => NextcloudShare(
|
||||
id: id,
|
||||
path: path,
|
||||
name: name,
|
||||
itemType: itemType,
|
||||
shareType: shareType,
|
||||
ownerDisplayName: ownerDisplayName,
|
||||
sharedWithDisplayName: sharedWithDisplayName,
|
||||
sharedAt: sharedAt,
|
||||
sharedWithMe: sharedWithMe,
|
||||
url: url,
|
||||
permissions: permissions,
|
||||
token: token,
|
||||
expireDate: expireDate,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ class FilesController extends ChangeNotifier
|
||||
NextcloudUserQuota? _quota;
|
||||
List<NextcloudActivity> _activities = [];
|
||||
bool _isLoading = false;
|
||||
bool _activating = false;
|
||||
String? _errorMessage;
|
||||
|
||||
FilesController(this.session) {
|
||||
@@ -147,7 +148,10 @@ class FilesController extends ChangeNotifier
|
||||
CachePolicy get cachePolicy => _cachePolicy;
|
||||
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
||||
@override
|
||||
bool get isLoading => _isLoading;
|
||||
// Also true while a freshly activated account is still restoring prefs
|
||||
// and while the session verifies it, so a switch shows a spinner instead
|
||||
// of briefly flashing the empty-folder message.
|
||||
bool get isLoading => _isLoading || _activating || session.isLoading;
|
||||
@override
|
||||
String? get errorMessage => _errorMessage;
|
||||
NextcloudUserQuota? get quota => _quota;
|
||||
@@ -360,9 +364,16 @@ class FilesController extends ChangeNotifier
|
||||
}
|
||||
|
||||
Future<void> _onAccountActivated() async {
|
||||
await _restoreDisplayPrefs();
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
await refreshData();
|
||||
_activating = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _restoreDisplayPrefs();
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
await refreshData();
|
||||
} finally {
|
||||
_activating = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -168,9 +168,9 @@ class ItemOperations {
|
||||
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
|
||||
);
|
||||
final existingNames = needsKeepBoth
|
||||
? (await files.fetchFolderListing(destFolderPath))
|
||||
.map((i) => i.name)
|
||||
.toSet()
|
||||
? (await files.fetchFolderListing(
|
||||
destFolderPath,
|
||||
)).map((i) => i.name).toSet()
|
||||
: <String>{};
|
||||
|
||||
var succeeded = 0;
|
||||
@@ -303,6 +303,20 @@ class ItemOperations {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateSharePermissions(
|
||||
NextcloudShare share,
|
||||
int permissions,
|
||||
) async {
|
||||
final service = session.service;
|
||||
if (service == null) return false;
|
||||
try {
|
||||
return await service.updateSharePermissions(share.id, permissions);
|
||||
} catch (e) {
|
||||
debugPrint('[ItemOperations] updateSharePermissions failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a share (from the file-details/Share sheet, not the Shares
|
||||
/// tab itself - see `SharesController.deleteShare` for that one, which
|
||||
/// additionally patches the tab's own list).
|
||||
|
||||
@@ -9,6 +9,11 @@ import '../widgets/noo/nav/noo_nav_style.dart';
|
||||
/// in Settings.
|
||||
enum SwipeAction { none, favorite, delete, share }
|
||||
|
||||
/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and
|
||||
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
|
||||
/// always shows the label.
|
||||
enum FabStyle { auto, mini, expanded }
|
||||
|
||||
/// Thumb/track presets for the video player's seek bar, matching the four
|
||||
/// combinations offered by other Material You media players: a Material 3
|
||||
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
||||
@@ -31,6 +36,7 @@ class SettingsController extends ChangeNotifier {
|
||||
static const _prefDefaultTab = 'ui_default_tab';
|
||||
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||
static const _prefFabStyle = 'ui_fab_style';
|
||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||
@@ -61,6 +67,7 @@ class SettingsController extends ChangeNotifier {
|
||||
|
||||
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
||||
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
||||
FabStyle _fabStyle = FabStyle.auto;
|
||||
|
||||
// A one-shot request for the shell to switch its active bottom-nav tab
|
||||
// (e.g. a search result landing on Files) - consumed and cleared by
|
||||
@@ -121,6 +128,7 @@ class SettingsController extends ChangeNotifier {
|
||||
|
||||
SwipeAction get swipeLeftAction => _swipeLeftAction;
|
||||
SwipeAction get swipeRightAction => _swipeRightAction;
|
||||
FabStyle get fabStyle => _fabStyle;
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
@@ -242,6 +250,14 @@ class SettingsController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
final fabName = prefs.getString(_prefFabStyle);
|
||||
if (fabName != null) {
|
||||
_fabStyle = FabStyle.values.firstWhere(
|
||||
(f) => f.name == fabName,
|
||||
orElse: () => _fabStyle,
|
||||
);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[SettingsController] Preference restore failed: $e');
|
||||
@@ -404,4 +420,11 @@ class SettingsController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name));
|
||||
}
|
||||
|
||||
void setFabStyle(FabStyle style) {
|
||||
if (_fabStyle == style) return;
|
||||
_fabStyle = style;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setString(_prefFabStyle, style.name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1538,6 +1538,22 @@ class NextcloudService {
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
|
||||
/// Changes a share's permission bitmask (1 read, 2 update, 4 create,
|
||||
/// 8 delete, 16 reshare).
|
||||
Future<bool> updateSharePermissions(String shareId, int permissions) async {
|
||||
final url =
|
||||
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares/$shareId?format=json';
|
||||
debugPrint('[Nextcloud OCS] Updating share $shareId permissions');
|
||||
|
||||
final response = await http.put(
|
||||
Uri.parse(url),
|
||||
headers: _headers,
|
||||
body: {'permissions': '$permissions'},
|
||||
);
|
||||
debugPrint('[Nextcloud OCS] Update share status: ${response.statusCode}');
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
|
||||
NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) {
|
||||
final path = (raw['path'] ?? '/').toString();
|
||||
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||||
|
||||
+50
-77
@@ -20,11 +20,8 @@ import '../widgets/settings/settings_tabs.dart';
|
||||
/// order: account card, accounts, security, file sync, files cache,
|
||||
/// appearance, tabs, action bar, swipe on a file).
|
||||
///
|
||||
/// Desktop is unchanged: a 2-column grid of cards ([_DesktopGrid]) wide
|
||||
/// enough to see every section at once, so it has no scroll-depth problem
|
||||
/// and needs no menu.
|
||||
///
|
||||
/// Mobile is a two-level menu, the way native iOS/Android Settings apps
|
||||
/// 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,
|
||||
@@ -47,18 +44,6 @@ import '../widgets/settings/settings_tabs.dart';
|
||||
class AccountView extends StatelessWidget {
|
||||
const AccountView({super.key});
|
||||
|
||||
static const _sections = <Widget>[
|
||||
SettingsAccountCard(),
|
||||
SettingsAccountsSection(),
|
||||
SettingsSecuritySection(),
|
||||
SettingsFileSyncSection(),
|
||||
SettingsFilesCacheSection(),
|
||||
SettingsAppearanceSection(),
|
||||
SettingsTabsSection(),
|
||||
SettingsActionBarSection(),
|
||||
SettingsSwipeSection(),
|
||||
];
|
||||
|
||||
static final _categories = <_SettingsCategory>[
|
||||
_SettingsCategory(
|
||||
title: 'Accounts',
|
||||
@@ -110,7 +95,7 @@ class AccountView extends StatelessWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
appBar: desktop
|
||||
? const NooToolbar(title: 'Settings')
|
||||
? _desktopBar(context, 'Settings')
|
||||
: NooTopBar(
|
||||
style: NooLayout.navStyle(context),
|
||||
title: 'Settings',
|
||||
@@ -119,7 +104,12 @@ class AccountView extends StatelessWidget {
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: desktop
|
||||
? _DesktopGrid(sections: _sections)
|
||||
? Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: _MobileMenu(categories: _categories),
|
||||
),
|
||||
)
|
||||
: _MobileMenu(categories: _categories),
|
||||
),
|
||||
);
|
||||
@@ -207,78 +197,61 @@ class _SettingsCategoryScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final desktop = NooLayout.isDesktop(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
appBar: NooTopBar(
|
||||
style: NooLayout.navStyle(context),
|
||||
title: category.title,
|
||||
leading: const NooTopBarBack(),
|
||||
),
|
||||
appBar: desktop
|
||||
? _desktopBar(context, category.title)
|
||||
: NooTopBar(
|
||||
style: NooLayout.navStyle(context),
|
||||
title: category.title,
|
||||
leading: const NooTopBarBack(),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.xxl,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: desktop ? 640 : double.infinity,
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.xxl,
|
||||
),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: [category.builder(context)],
|
||||
),
|
||||
),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: [category.builder(context)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits the sections between two columns rather than a strict grid, since
|
||||
/// each card's content height varies a lot (the tab reorder list and the
|
||||
/// accounts list can both run much taller than, say, Security) - a fixed
|
||||
/// grid would either clip content or leave large gaps.
|
||||
class _DesktopGrid extends StatelessWidget {
|
||||
final List<Widget> sections;
|
||||
const _DesktopGrid({required this.sections});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final left = <Widget>[];
|
||||
final right = <Widget>[];
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
(i.isEven ? left : right).add(sections[i]);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(NooSpace.xl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1040),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
PreferredSizeWidget _desktopBar(BuildContext context, String title) {
|
||||
final top = MediaQuery.paddingOf(context).top;
|
||||
return PreferredSize(
|
||||
preferredSize: Size.fromHeight(NooToolbar.outerHeight + top),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: top),
|
||||
child: NooToolbar(
|
||||
titleWidget: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: NooSpace.sm,
|
||||
children: [
|
||||
Expanded(child: _Column(children: left)),
|
||||
const SizedBox(width: NooSpace.xl),
|
||||
Expanded(child: _Column(children: right)),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
tooltip: 'Back',
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
Text(title),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Column extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const _Column({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final child in children) ...[
|
||||
child,
|
||||
const SizedBox(height: NooSpace.xl),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -586,7 +586,9 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop ? 5 : 2,
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
childAspectRatio: isDesktop ? 0.92 : 0.85,
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../widgets/noo/overlays/noo_overlay_header.dart';
|
||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/share_sheet.dart';
|
||||
import '../widgets/viewer/media_action_bar.dart';
|
||||
import '../widgets/viewer/media_details_panel.dart';
|
||||
import '../widgets/viewer/media_image_preview.dart';
|
||||
import '../widgets/viewer/media_pdf_preview.dart';
|
||||
import '../widgets/viewer/media_text_preview.dart';
|
||||
@@ -154,6 +155,43 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
/// also drag the gallery to the next item.
|
||||
bool _isZoomed = false;
|
||||
|
||||
final Map<int, Offset> _pointerStart = {};
|
||||
bool _multiTouch = false;
|
||||
|
||||
void _onPointerDown(PointerDownEvent e) {
|
||||
if (_pointerStart.isNotEmpty) _multiTouch = true;
|
||||
_pointerStart[e.pointer] = e.position;
|
||||
}
|
||||
|
||||
// Raw pointer events rather than a drag recognizer, so this never competes
|
||||
// with the PageView / InteractiveViewer gestures in the arena.
|
||||
void _onPointerUp(PointerUpEvent e) {
|
||||
final start = _pointerStart.remove(e.pointer);
|
||||
final wasMulti = _multiTouch;
|
||||
if (_pointerStart.isEmpty) _multiTouch = false;
|
||||
if (start == null || wasMulti || _isZoomed || !_isSwipeable) return;
|
||||
final delta = e.position - start;
|
||||
if (delta.dy < -80 && delta.dy.abs() > delta.dx.abs() * 1.5) {
|
||||
_openDetails();
|
||||
}
|
||||
}
|
||||
|
||||
final _panelKey = GlobalKey<MediaDetailsPanelState>();
|
||||
|
||||
void _openDetails() {
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
DetailsSheet.show(context, _currentItem);
|
||||
return;
|
||||
}
|
||||
setState(() => _controlsVisible = true);
|
||||
_panelKey.currentState?.expand();
|
||||
}
|
||||
|
||||
void _onPointerCancel(PointerCancelEvent e) {
|
||||
_pointerStart.remove(e.pointer);
|
||||
if (_pointerStart.isEmpty) _multiTouch = false;
|
||||
}
|
||||
|
||||
NextcloudItem get _currentItem => _mediaItems[_currentIndex];
|
||||
|
||||
bool get _isOffline => widget.localPathResolver != null;
|
||||
@@ -394,13 +432,25 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
backgroundColor: stageColor,
|
||||
body: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _controlsVisible = !_controlsVisible),
|
||||
onTap: () {
|
||||
final panel = _panelKey.currentState;
|
||||
if (panel != null && panel.isExpanded) {
|
||||
panel.collapse();
|
||||
return;
|
||||
}
|
||||
setState(() => _controlsVisible = !_controlsVisible);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: stageColor,
|
||||
child: _buildBody(context, session),
|
||||
child: Listener(
|
||||
onPointerDown: _onPointerDown,
|
||||
onPointerUp: _onPointerUp,
|
||||
onPointerCancel: _onPointerCancel,
|
||||
child: Container(
|
||||
color: stageColor,
|
||||
child: _buildBody(context, session),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_downloadProgress != null)
|
||||
@@ -522,25 +572,35 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: MediaActionBar(
|
||||
isFavorite: _currentItem.isFavorite,
|
||||
isBusy: _isBusy,
|
||||
// Favorite/delete/download-to-device all need a live
|
||||
// server - hidden rather than shown-and-failing while
|
||||
// browsing an already-local file from the Offline tab.
|
||||
showServerActions: !_isOffline,
|
||||
transportControls:
|
||||
_isSwipeable &&
|
||||
_currentItem.type == NextcloudItemType.video &&
|
||||
_videoController != null
|
||||
? VideoTransportControls(controller: _videoController!)
|
||||
: null,
|
||||
onShare: () => ShareSheet.show(context, _currentItem),
|
||||
onFavorite: () => _toggleFavorite(ops),
|
||||
onDelete: () => _deleteCurrentItem(ops),
|
||||
onOpenExternally: () => _openExternally(session),
|
||||
onDownload: () => _downloadToDevice(session),
|
||||
onDetails: () => DetailsSheet.show(context, _currentItem),
|
||||
child: MediaDetailsPanel(
|
||||
key: _panelKey,
|
||||
item: _currentItem,
|
||||
builder: (context, handle, below) => MediaActionBar(
|
||||
onDetails: NooLayout.isDesktop(context)
|
||||
? _openDetails
|
||||
: null,
|
||||
handle: NooLayout.isDesktop(context) ? null : handle,
|
||||
below: NooLayout.isDesktop(context) ? null : below,
|
||||
isFavorite: _currentItem.isFavorite,
|
||||
isBusy: _isBusy,
|
||||
// Favorite/delete/download-to-device all need a live
|
||||
// server - hidden rather than shown-and-failing while
|
||||
// browsing an already-local file from the Offline tab.
|
||||
showServerActions: !_isOffline,
|
||||
transportControls:
|
||||
_isSwipeable &&
|
||||
_currentItem.type == NextcloudItemType.video &&
|
||||
_videoController != null
|
||||
? VideoTransportControls(
|
||||
controller: _videoController!,
|
||||
)
|
||||
: null,
|
||||
onShare: () => ShareSheet.show(context, _currentItem),
|
||||
onFavorite: () => _toggleFavorite(ops),
|
||||
onDelete: () => _deleteCurrentItem(ops),
|
||||
onOpenExternally: () => _openExternally(session),
|
||||
onDownload: () => _downloadToDevice(session),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -659,6 +719,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
default:
|
||||
if (_isPdf) {
|
||||
return MediaPdfPreview(
|
||||
topInset: _controlsVisible
|
||||
? MediaQuery.paddingOf(context).top + 60
|
||||
: 0,
|
||||
item: widget.item,
|
||||
session: session,
|
||||
localPathResolver: widget.localPathResolver,
|
||||
|
||||
@@ -754,7 +754,9 @@ class _FilesViewState extends State<FilesView> {
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop ? 5 : 2,
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
@@ -1323,6 +1325,9 @@ class _FilesViewState extends State<FilesView> {
|
||||
selected: isSelected,
|
||||
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop),
|
||||
onTap: () => _handleItemTap(context, item, picking: picking),
|
||||
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||
? null
|
||||
: () => _toggleSelection(item),
|
||||
onMore: !_isSelecting && !picking
|
||||
? () => _showItemActions(context, item)
|
||||
: null,
|
||||
|
||||
@@ -284,7 +284,9 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
|
||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: desktop ? 5 : 2,
|
||||
crossAxisCount: desktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: desktop ? 16 : 10,
|
||||
mainAxisSpacing: desktop ? 16 : 10,
|
||||
|
||||
@@ -342,7 +342,11 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
itemCount: group.items.length,
|
||||
columns: isDesktop
|
||||
? NooPhotoGrid.desktopColumns
|
||||
: NooPhotoGrid.mobileColumns,
|
||||
: NooLayout.gridColumns(
|
||||
context,
|
||||
phone: NooPhotoGrid.mobileColumns,
|
||||
minTile: 130,
|
||||
),
|
||||
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
|
||||
|
||||
@@ -209,7 +209,9 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: desktop ? 5 : 2,
|
||||
crossAxisCount: desktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: desktop ? 16 : 10,
|
||||
mainAxisSpacing: desktop ? 16 : 10,
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../providers/settings_controller.dart';
|
||||
import '../providers/trash_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_avatar.dart';
|
||||
import '../views/login_view.dart';
|
||||
import 'noo/core/noo_badge.dart';
|
||||
import 'noo/lists/noo_settings_row.dart';
|
||||
import 'shell/shell_common.dart';
|
||||
@@ -43,9 +44,16 @@ Future<void> showAvatarMenu(BuildContext context) {
|
||||
);
|
||||
}
|
||||
|
||||
class _AvatarMenuContent extends StatelessWidget {
|
||||
class _AvatarMenuContent extends StatefulWidget {
|
||||
const _AvatarMenuContent();
|
||||
|
||||
@override
|
||||
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
|
||||
}
|
||||
|
||||
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
@@ -124,7 +132,7 @@ class _AvatarMenuContent extends StatelessWidget {
|
||||
// header reads as a continuation of the button that
|
||||
// opened it rather than a disconnected card.
|
||||
InkWell(
|
||||
onTap: closeAndOpenSettings,
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(NooSpace.md),
|
||||
child: Row(
|
||||
@@ -161,10 +169,87 @@ class _AvatarMenuContent extends StatelessWidget {
|
||||
current: true,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colors.fg1.withValues(alpha: 0.08),
|
||||
),
|
||||
child: Icon(
|
||||
_expanded
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 20,
|
||||
color: colors.fg2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: NooMotion.fast,
|
||||
curve: NooMotion.ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _expanded
|
||||
? Column(
|
||||
children: [
|
||||
for (final account in session.accounts)
|
||||
if (account.id != session.activeAccountId)
|
||||
_OtherAccountRow(
|
||||
name: account.username,
|
||||
host: serverHost(account.serverUrl),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
session.switchAccount(account.id);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.md,
|
||||
0,
|
||||
NooSpace.md,
|
||||
NooSpace.md,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _AccountButton(
|
||||
icon: LucideIcons.userPlus,
|
||||
label: 'Add Account',
|
||||
onTap: () {
|
||||
final nav = Navigator.of(
|
||||
context,
|
||||
);
|
||||
nav.pop();
|
||||
nav.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const LoginView(
|
||||
isAddingAccount: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _AccountButton(
|
||||
icon: LucideIcons.users,
|
||||
label: 'Manage Accounts',
|
||||
onTap: closeAndOpenSettings,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
if (hiddenTabs.isNotEmpty) ...[
|
||||
Divider(height: 1, color: colors.line),
|
||||
for (final tab in hiddenTabs)
|
||||
@@ -201,3 +286,100 @@ class _AvatarMenuContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OtherAccountRow extends StatelessWidget {
|
||||
final String name;
|
||||
final String host;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _OtherAccountRow({
|
||||
required this.name,
|
||||
required this.host,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(NooSpace.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.bodyL.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
host,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
NooAvatar(initials: accountInitial(name), current: false, size: 40),
|
||||
const SizedBox(width: 44),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AccountButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final radius = BorderRadius.circular(NooRadii.input);
|
||||
return Material(
|
||||
color: colors.bg,
|
||||
borderRadius: radius,
|
||||
child: InkWell(
|
||||
borderRadius: radius,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: colors.fg2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.body.copyWith(color: colors.fg1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ enum _DetailsTab { info, versions, activity }
|
||||
/// and the "open externally" flow for unsupported file types.
|
||||
///
|
||||
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
|
||||
/// desktop path in [show] passes [_DetailsBody] straight to [showNooDialog],
|
||||
/// desktop path in [show] passes [DetailsBody] straight to [showNooDialog],
|
||||
/// which renders the header itself via `leading`/`title`/`subtitle`.
|
||||
class DetailsSheet extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
@@ -68,7 +68,7 @@ class DetailsSheet extends StatelessWidget {
|
||||
leading: detailsFileTile(item),
|
||||
title: item.name,
|
||||
subtitle: detailsMetaLine(item),
|
||||
children: [_DetailsBody(item: item)],
|
||||
children: [DetailsBody(item: item)],
|
||||
);
|
||||
}
|
||||
return showNooSheet(context, children: [DetailsSheet(item: item)]);
|
||||
@@ -86,7 +86,7 @@ class DetailsSheet extends StatelessWidget {
|
||||
onClose: () => Navigator.pop(context),
|
||||
),
|
||||
const SizedBox(height: NooSpace.lg),
|
||||
_DetailsBody(item: item),
|
||||
DetailsBody(item: item),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -102,16 +102,16 @@ class DetailsSheet extends StatelessWidget {
|
||||
/// guard), so switching tabs via a bare `switch` on the selected type would
|
||||
/// tear down and rebuild whichever tab isn't showing, discarding its
|
||||
/// fetched data and re-requesting it every time the user switched back.
|
||||
class _DetailsBody extends StatefulWidget {
|
||||
class DetailsBody extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const _DetailsBody({required this.item});
|
||||
const DetailsBody({super.key, required this.item});
|
||||
|
||||
@override
|
||||
State<_DetailsBody> createState() => _DetailsBodyState();
|
||||
State<DetailsBody> createState() => _DetailsBodyState();
|
||||
}
|
||||
|
||||
class _DetailsBodyState extends State<_DetailsBody> {
|
||||
class _DetailsBodyState extends State<DetailsBody> {
|
||||
_DetailsTab _tab = _DetailsTab.info;
|
||||
|
||||
@override
|
||||
|
||||
@@ -17,6 +17,9 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double borderRadius;
|
||||
|
||||
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
|
||||
final BorderRadius? radius;
|
||||
|
||||
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
|
||||
/// explicit value (e.g. from user settings) to make it adjustable.
|
||||
final double blurSigma;
|
||||
@@ -35,6 +38,7 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
this.borderRadius = 0,
|
||||
this.radius,
|
||||
this.blurSigma = 20,
|
||||
this.opacity = 0.8,
|
||||
this.color,
|
||||
@@ -42,15 +46,16 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shape = radius ?? BorderRadius.circular(borderRadius);
|
||||
final tint = color ?? context.nooColors.surface;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
borderRadius: shape,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: tint.withValues(alpha: opacity),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
borderRadius: shape,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
|
||||
@@ -154,6 +154,7 @@ class NooFileTableRow extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDoubleTap;
|
||||
final VoidCallback? onSecondaryTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
/// Overflow menu tap. The button is hidden when null.
|
||||
final VoidCallback? onMore;
|
||||
@@ -171,6 +172,7 @@ class NooFileTableRow extends StatelessWidget {
|
||||
this.onTap,
|
||||
this.onDoubleTap,
|
||||
this.onSecondaryTap,
|
||||
this.onLongPress,
|
||||
this.onMore,
|
||||
});
|
||||
|
||||
@@ -193,6 +195,7 @@ class NooFileTableRow extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
onDoubleTap: onDoubleTap,
|
||||
onSecondaryTap: onSecondaryTap,
|
||||
onLongPress: onLongPress,
|
||||
child: SizedBox(
|
||||
height: NooSizes.rowDesktop,
|
||||
child: _TableColumns(
|
||||
|
||||
@@ -22,6 +22,9 @@ class NooGroupedList extends StatefulWidget {
|
||||
final String? aside;
|
||||
final List<Widget> children;
|
||||
final Widget? footer;
|
||||
|
||||
/// Shown between [label] and the card (e.g. a `NooInfoNote`).
|
||||
final Widget? notice;
|
||||
final bool collapsible;
|
||||
final bool initiallyExpanded;
|
||||
|
||||
@@ -31,6 +34,7 @@ class NooGroupedList extends StatefulWidget {
|
||||
this.aside,
|
||||
required this.children,
|
||||
this.footer,
|
||||
this.notice,
|
||||
this.collapsible = false,
|
||||
this.initiallyExpanded = true,
|
||||
});
|
||||
@@ -58,6 +62,10 @@ class _NooGroupedListState extends State<NooGroupedList> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (widget.notice != null) ...[
|
||||
widget.notice!,
|
||||
const SizedBox(height: NooSpace.md),
|
||||
],
|
||||
AnimatedCrossFade(
|
||||
duration: NooMotion.fast,
|
||||
sizeCurve: NooMotion.ease,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
/// Tinted info callout (info-soft fill, info icon) for a short notice that
|
||||
/// must be noticed immediately, unlike a muted footer/subtitle line.
|
||||
class NooInfoNote extends StatelessWidget {
|
||||
final String message;
|
||||
|
||||
const NooInfoNote({super.key, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.infoSoft,
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
border: Border.all(color: colors.info.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: Icon(LucideIcons.info, size: 18, color: colors.info),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: NooText.body.copyWith(color: colors.fg1, height: 1.35),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,58 +48,68 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
|
||||
);
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(NooSizes.toolbar);
|
||||
Size get preferredSize => const Size.fromHeight(outerHeight);
|
||||
|
||||
static const _margin = EdgeInsets.fromLTRB(12, 8, 12, 4);
|
||||
|
||||
/// Bar height plus the margin that makes it float.
|
||||
static const double outerHeight = NooSizes.toolbar + 12;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1);
|
||||
|
||||
return Material(
|
||||
color: backgroundColor ?? colors.surface,
|
||||
child: Container(
|
||||
height: NooSizes.toolbar,
|
||||
padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: colors.line)),
|
||||
return Padding(
|
||||
padding: _margin,
|
||||
child: Material(
|
||||
color: backgroundColor ?? colors.surface,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
side: BorderSide(color: colors.line),
|
||||
),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (showHistoryNav)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
_HistoryButton(
|
||||
icon: LucideIcons.chevronLeft,
|
||||
tooltip: 'Back',
|
||||
onTap: onBack,
|
||||
),
|
||||
_HistoryButton(
|
||||
icon: LucideIcons.chevronRight,
|
||||
tooltip: 'Forward',
|
||||
onTap: onForward,
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
height: NooSizes.toolbar,
|
||||
padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (showHistoryNav)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
_HistoryButton(
|
||||
icon: LucideIcons.chevronLeft,
|
||||
tooltip: 'Back',
|
||||
onTap: onBack,
|
||||
),
|
||||
_HistoryButton(
|
||||
icon: LucideIcons.chevronRight,
|
||||
tooltip: 'Forward',
|
||||
onTap: onForward,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: DefaultTextStyle(
|
||||
style: titleStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: titleWidget ?? Text(title ?? ''),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: DefaultTextStyle(
|
||||
style: titleStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: titleWidget ?? Text(title ?? ''),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (search != null) SizedBox(width: 260, child: search),
|
||||
...actions,
|
||||
],
|
||||
if (search != null) SizedBox(width: 260, child: search),
|
||||
...actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,8 +11,16 @@ class NooLayout {
|
||||
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
|
||||
static const double desktopBreakpoint = 900;
|
||||
|
||||
static bool isDesktop(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width >= desktopBreakpoint;
|
||||
/// 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.
|
||||
static const double tabletShortestSide = 600;
|
||||
|
||||
static bool isDesktop(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return size.width >= desktopBreakpoint &&
|
||||
size.shortestSide >= tabletShortestSide;
|
||||
}
|
||||
|
||||
static NooNavStyle navStyle(BuildContext context) =>
|
||||
NooNavStyle.fromPlatform(Theme.of(context).platform);
|
||||
@@ -24,4 +32,16 @@ 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).
|
||||
static int gridColumns(
|
||||
BuildContext context, {
|
||||
required int phone,
|
||||
required double minTile,
|
||||
}) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
return (width / minTile).floor().clamp(phone, 12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ class NooShareSection extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title, style: NooText.sectionTitle.copyWith(color: colors.fg1)),
|
||||
child: Text(
|
||||
title,
|
||||
style: NooText.sectionTitle.copyWith(color: colors.fg1),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
@@ -60,10 +63,13 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
final String name;
|
||||
final String? subtitle;
|
||||
final bool owner;
|
||||
|
||||
/// Pill label for non-owners, e.g. "Can edit" / "Can view".
|
||||
final String permission;
|
||||
final VoidCallback? onPermissionTap;
|
||||
final bool permissionLoading;
|
||||
final String ownerLabel;
|
||||
|
||||
/// Replaces the owner label / permission pill (e.g. a plain search result).
|
||||
final Widget? trailing;
|
||||
|
||||
@@ -75,6 +81,7 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
this.owner = false,
|
||||
this.permission = 'Can edit',
|
||||
this.onPermissionTap,
|
||||
this.permissionLoading = false,
|
||||
this.ownerLabel = 'Owner',
|
||||
this.trailing,
|
||||
});
|
||||
@@ -121,7 +128,11 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
else if (owner)
|
||||
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
|
||||
else
|
||||
NooPermissionPill(label: permission, onTap: onPermissionTap),
|
||||
NooPermissionPill(
|
||||
label: permission,
|
||||
onTap: onPermissionTap,
|
||||
loading: permissionLoading,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -133,8 +144,14 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
class NooPermissionPill extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final bool loading;
|
||||
|
||||
const NooPermissionPill({super.key, required this.label, this.onTap});
|
||||
const NooPermissionPill({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.loading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -152,9 +169,21 @@ class NooPermissionPill extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: NooText.label.copyWith(height: 1, color: colors.fg1)),
|
||||
Text(
|
||||
label,
|
||||
style: NooText.label.copyWith(height: 1, color: colors.fg1),
|
||||
),
|
||||
const SizedBox(width: NooSpace.xxs),
|
||||
Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
|
||||
loading
|
||||
? SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.fg2,
|
||||
),
|
||||
)
|
||||
: Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -64,6 +64,10 @@ class _NooSheetBody extends StatefulWidget {
|
||||
class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||
bool _needsPeek = false;
|
||||
|
||||
// Keeps the sheet's content (and its State) alive when the body swaps
|
||||
// between the plain and draggable layouts, instead of rebuilding it.
|
||||
final _contentKey = GlobalKey();
|
||||
|
||||
void _handleOverflow(ScrollMetrics metrics) {
|
||||
if (_needsPeek || metrics.maxScrollExtent <= 0) return;
|
||||
// `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids
|
||||
@@ -84,6 +88,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
||||
child: Column(
|
||||
key: _contentKey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
|
||||
@@ -48,6 +48,8 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
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),
|
||||
@@ -67,6 +69,13 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
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)],
|
||||
),
|
||||
const SizedBox(height: NooSpace.xl),
|
||||
SettingsSection(
|
||||
title: 'Advanced appearance',
|
||||
children: [
|
||||
@@ -291,6 +300,64 @@ class _NavMenuStyleRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _FabStyleRow extends StatelessWidget {
|
||||
final SettingsController settings;
|
||||
|
||||
const _FabStyleRow({required this.settings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.circlePlus, size: 20, color: colors.fg2),
|
||||
const SizedBox(width: 14),
|
||||
Text(
|
||||
'Upload button size',
|
||||
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
NooSegmentedControl<FabStyle>(
|
||||
fill: true,
|
||||
value: settings.fabStyle,
|
||||
onChanged: settings.setFabStyle,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: FabStyle.auto,
|
||||
icon: LucideIcons.wandSparkles,
|
||||
label: 'Auto',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FabStyle.mini,
|
||||
icon: LucideIcons.minimize2,
|
||||
label: 'Mini',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FabStyle.expanded,
|
||||
icon: LucideIcons.maximize2,
|
||||
label: 'Expanded',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current accent-color choice, shown as a small filled circle in the
|
||||
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
|
||||
/// color instead of a fixed swatch.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/lists/noo_grouped_list.dart';
|
||||
import '../noo/lists/noo_info_note.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../noo/overlays/noo_dialog.dart';
|
||||
import '../noo/overlays/noo_sheet.dart';
|
||||
@@ -17,10 +18,14 @@ class SettingsSection extends StatelessWidget {
|
||||
final String? subtitle;
|
||||
final List<Widget> children;
|
||||
|
||||
/// Shown as a prominent [NooInfoNote] above the rows.
|
||||
final String? notice;
|
||||
|
||||
const SettingsSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.notice,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@@ -30,14 +35,8 @@ class SettingsSection extends StatelessWidget {
|
||||
if (!NooLayout.isDesktop(context)) {
|
||||
return NooGroupedList(
|
||||
label: title,
|
||||
notice: notice != null ? NooInfoNote(message: notice!) : null,
|
||||
footer: subtitle != null ? Text(subtitle!) : null,
|
||||
// Not collapsible: each mobile Settings section now renders on its
|
||||
// own pushed screen (see `account_view.dart`'s doc comment for the
|
||||
// menu-then-pushed-screen pattern), so there's no long single-scroll
|
||||
// page left to collapse sections *within* - an earlier design had
|
||||
// every section inline in one column and made them individually
|
||||
// collapsible for exactly that reason; that's gone now that each
|
||||
// one is already isolated on its own screen.
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
@@ -57,6 +56,10 @@ class SettingsSection extends StatelessWidget {
|
||||
Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
],
|
||||
const SizedBox(height: NooSpace.md),
|
||||
if (notice != null) ...[
|
||||
NooInfoNote(message: notice!),
|
||||
const SizedBox(height: NooSpace.md),
|
||||
],
|
||||
FlatRowGroup(children: children),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -37,7 +37,11 @@ class SettingsSwipeSection extends StatelessWidget {
|
||||
NooSettingsRow(
|
||||
label: Text(_swipeActionLabel(action)),
|
||||
trailing: action == current
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 18,
|
||||
color: context.nooColors.accentText,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
onChanged(action);
|
||||
@@ -55,6 +59,7 @@ 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,
|
||||
|
||||
+104
-17
@@ -19,6 +19,7 @@ import 'noo/core/noo_avatar.dart';
|
||||
import 'noo/core/noo_button.dart';
|
||||
import 'noo/core/noo_chip.dart';
|
||||
import 'noo/core/noo_toggle.dart';
|
||||
import 'noo/lists/noo_grouped_list.dart';
|
||||
import 'noo/lists/noo_settings_row.dart';
|
||||
import 'noo/media/noo_photo_tile.dart';
|
||||
import 'noo/noo_layout.dart';
|
||||
@@ -98,6 +99,8 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
bool _isSearching = false;
|
||||
bool _isAddingPerson = false;
|
||||
bool _isTogglingLink = false;
|
||||
String? _pendingShareeKey;
|
||||
final Set<String> _updatingShareIds = {};
|
||||
bool _isSharingFile = false;
|
||||
bool _showInherited = false;
|
||||
final _peopleController = TextEditingController();
|
||||
@@ -183,15 +186,20 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
String _shareeKey(NextcloudSharee s) => '${s.shareTypeValue}:${s.shareWith}';
|
||||
|
||||
Future<void> _addSharee(NextcloudSharee sharee) async {
|
||||
if (_pendingShareeKey != null) return;
|
||||
final ops = context.read<ItemOperations>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
setState(() => _pendingShareeKey = _shareeKey(sharee));
|
||||
final share = await ops.createShare(
|
||||
path: widget.item.path,
|
||||
shareType: sharee.shareTypeValue,
|
||||
shareWith: sharee.shareWith,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _pendingShareeKey = null);
|
||||
if (share == null) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -353,36 +361,93 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small popup with the one action the compact [NooPersonAccessRow]
|
||||
/// permission pill has no room for - removing that person's access. There's
|
||||
/// no permission-update endpoint on [ItemOperations] yet, so the pill
|
||||
/// itself is read-only (see the rebuild report).
|
||||
/// Permission menu for a person row: Can view / Can edit, then removing
|
||||
/// access. Keeps the reshare bit (16) as it was.
|
||||
void _openPersonMenu(NextcloudShare share) {
|
||||
final label = share.sharedWithDisplayName ?? 'Shared';
|
||||
final canEdit = (share.permissions & 2) != 0;
|
||||
|
||||
void remove() {
|
||||
Navigator.pop(context);
|
||||
_removeShare(share);
|
||||
}
|
||||
|
||||
final row = NooSettingsRow(
|
||||
icon: LucideIcons.userMinus,
|
||||
label: const Text('Remove access'),
|
||||
danger: true,
|
||||
onTap: remove,
|
||||
void choose(bool edit) {
|
||||
Navigator.pop(context);
|
||||
_setPermission(share, edit);
|
||||
}
|
||||
|
||||
Widget check(bool on) => Icon(
|
||||
LucideIcons.check,
|
||||
size: 18,
|
||||
color: on ? context.nooColors.accentText : Colors.transparent,
|
||||
);
|
||||
|
||||
final rows = <Widget>[
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eye,
|
||||
label: const Text('Can view'),
|
||||
trailing: check(!canEdit),
|
||||
onTap: () => choose(false),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.pencil,
|
||||
label: const Text('Can edit'),
|
||||
trailing: check(canEdit),
|
||||
onTap: () => choose(true),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.userMinus,
|
||||
label: const Text('Remove access'),
|
||||
danger: true,
|
||||
onTap: remove,
|
||||
),
|
||||
];
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
showNooDialog(context, title: label, children: [row]);
|
||||
showNooDialog(
|
||||
context,
|
||||
title: label,
|
||||
children: [Column(children: rows)],
|
||||
);
|
||||
} else {
|
||||
showNooSheet(
|
||||
context,
|
||||
children: [
|
||||
NooOverlayHeader(title: label, onClose: () => Navigator.pop(context)),
|
||||
row,
|
||||
NooGroupedList(children: rows),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setPermission(NextcloudShare share, bool edit) async {
|
||||
if (((share.permissions & 2) != 0) == edit) return;
|
||||
final ops = context.read<ItemOperations>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final reshare = share.permissions & 16;
|
||||
final base = edit ? (share.isFolder ? 15 : 3) : 1;
|
||||
final permissions = base | reshare;
|
||||
setState(() => _updatingShareIds.add(share.id));
|
||||
final ok = await ops.updateSharePermissions(share, permissions);
|
||||
if (!mounted) return;
|
||||
setState(() => _updatingShareIds.remove(share.id));
|
||||
if (ok) {
|
||||
setState(() {
|
||||
_shares = [
|
||||
for (final s in _shares)
|
||||
s.id == share.id ? s.withPermissions(permissions) : s,
|
||||
];
|
||||
});
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not change permission'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _peopleSection(
|
||||
NooColors colors,
|
||||
SessionController session,
|
||||
@@ -416,7 +481,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
for (final sharee in _searchResults)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
onTap: () => _addSharee(sharee),
|
||||
onTap: _pendingShareeKey == null ? () => _addSharee(sharee) : null,
|
||||
child: NooPersonAccessRow(
|
||||
avatar: NooAvatar(
|
||||
initials: _initial(sharee.label),
|
||||
@@ -427,7 +492,13 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
),
|
||||
name: sharee.label,
|
||||
subtitle: sharee.subtitle,
|
||||
trailing: const SizedBox.shrink(),
|
||||
trailing: _pendingShareeKey == _shareeKey(sharee)
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (_searchResults.isNotEmpty)
|
||||
@@ -463,7 +534,10 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
? 'Invited by email'
|
||||
: null),
|
||||
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
|
||||
onPermissionTap: () => _openPersonMenu(share),
|
||||
permissionLoading: _updatingShareIds.contains(share.id),
|
||||
onPermissionTap: _updatingShareIds.contains(share.id)
|
||||
? null
|
||||
: () => _openPersonMenu(share),
|
||||
),
|
||||
if (_inherited.isNotEmpty) ...[
|
||||
const SizedBox(height: NooSpace.xs),
|
||||
@@ -625,9 +699,22 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
const SizedBox(height: 22),
|
||||
NooShareSection(
|
||||
title: 'Share link',
|
||||
trailing: NooToggle(
|
||||
checked: hasLink,
|
||||
onChanged: _isTogglingLink ? null : _toggleLink,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isTogglingLink) ...[
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
],
|
||||
NooToggle(
|
||||
checked: hasLink,
|
||||
onChanged: _isTogglingLink ? null : _toggleLink,
|
||||
),
|
||||
],
|
||||
),
|
||||
caption: hasLink
|
||||
? null
|
||||
|
||||
@@ -22,9 +22,14 @@ class MediaActionBar extends StatelessWidget {
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onOpenExternally;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onDetails;
|
||||
final VoidCallback? onDetails;
|
||||
final Widget? handle;
|
||||
final Widget? below;
|
||||
|
||||
const MediaActionBar({
|
||||
this.onDetails,
|
||||
this.handle,
|
||||
this.below,
|
||||
super.key,
|
||||
required this.isFavorite,
|
||||
required this.isBusy,
|
||||
@@ -35,7 +40,6 @@ class MediaActionBar extends StatelessWidget {
|
||||
required this.onDelete,
|
||||
required this.onOpenExternally,
|
||||
required this.onDownload,
|
||||
required this.onDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -48,11 +52,15 @@ class MediaActionBar extends StatelessWidget {
|
||||
// short and leaving that strip unstyled - only the actual row content
|
||||
// needs padding up and away from the gesture area.
|
||||
return FrostedGlassContainer(
|
||||
radius: const BorderRadius.vertical(
|
||||
top: Radius.circular(NooRadii.sheetTop),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
?handle,
|
||||
if (transportControls != null) ...[
|
||||
transportControls!,
|
||||
Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)),
|
||||
@@ -94,17 +102,18 @@ class MediaActionBar extends StatelessWidget {
|
||||
ViewerIconButton(
|
||||
icon: LucideIcons.trash2,
|
||||
tooltip: 'Delete',
|
||||
color: colors.danger,
|
||||
onTap: isBusy ? null : onDelete,
|
||||
),
|
||||
ViewerIconButton(
|
||||
icon: LucideIcons.info,
|
||||
tooltip: 'Details',
|
||||
onTap: onDetails,
|
||||
),
|
||||
if (onDetails != null)
|
||||
ViewerIconButton(
|
||||
icon: LucideIcons.info,
|
||||
tooltip: 'Details',
|
||||
onTap: onDetails,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?below,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../details/details_sheet.dart';
|
||||
import '../noo/overlays/noo_overlay_header.dart';
|
||||
|
||||
/// Builds the viewer's bottom bar with a drag [handle] on top and the
|
||||
/// expandable [below] details region underneath its action row.
|
||||
typedef MediaPanelBarBuilder =
|
||||
Widget Function(BuildContext context, Widget handle, Widget below);
|
||||
|
||||
/// The media viewer's bottom bar as a draggable panel: dragging it (or
|
||||
/// swiping up on the media, via [MediaDetailsPanelState.expand]) grows the
|
||||
/// panel upward so the action row rides on top of the file's details instead
|
||||
/// of the details covering it in a separate modal sheet.
|
||||
class MediaDetailsPanel extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
final MediaPanelBarBuilder builder;
|
||||
|
||||
const MediaDetailsPanel({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MediaDetailsPanel> createState() => MediaDetailsPanelState();
|
||||
}
|
||||
|
||||
class MediaDetailsPanelState extends State<MediaDetailsPanel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
);
|
||||
double _maxHeight = 1;
|
||||
|
||||
bool get isExpanded => _c.value > 0.5;
|
||||
|
||||
void expand() => _c.animateTo(1, curve: Curves.easeOutCubic);
|
||||
void collapse() => _c.animateTo(0, curve: Curves.easeOutCubic);
|
||||
void toggle() => isExpanded ? collapse() : expand();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
_c.value -= (d.primaryDelta ?? 0) / _maxHeight;
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
final v = d.primaryVelocity ?? 0;
|
||||
if (v < -400) {
|
||||
expand();
|
||||
} else if (v > 400) {
|
||||
collapse();
|
||||
} else {
|
||||
toggleToNearest();
|
||||
}
|
||||
}
|
||||
|
||||
void toggleToNearest() => _c.value > 0.5 ? expand() : collapse();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
_maxHeight = MediaQuery.sizeOf(context).height * 0.55;
|
||||
|
||||
final handle = Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 2),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.fg3.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final below = AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (context, _) {
|
||||
if (_c.value == 0) return const SizedBox.shrink();
|
||||
return SizedBox(
|
||||
height: _c.value * _maxHeight,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
minHeight: 0,
|
||||
maxHeight: _maxHeight,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.lg,
|
||||
NooSpace.md,
|
||||
NooSpace.lg,
|
||||
NooSpace.lg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
NooOverlayHeader(
|
||||
leading: detailsFileTile(widget.item),
|
||||
title: widget.item.name,
|
||||
subtitle: detailsMetaLine(widget.item),
|
||||
onClose: collapse,
|
||||
),
|
||||
const SizedBox(height: NooSpace.lg),
|
||||
DetailsBody(
|
||||
key: ValueKey(widget.item.id),
|
||||
item: widget.item,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: _onDragEnd,
|
||||
child: widget.builder(context, handle, below),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,12 @@ 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).
|
||||
final double topInset;
|
||||
|
||||
const MediaPdfPreview({
|
||||
super.key,
|
||||
this.topInset = 0,
|
||||
required this.item,
|
||||
required this.session,
|
||||
this.localPathResolver,
|
||||
@@ -29,8 +33,14 @@ class MediaPdfPreview extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
||||
// Only the first few pages are measured; longer documents are assumed
|
||||
// taller than any screen.
|
||||
static const _sizedPages = 3;
|
||||
|
||||
PdfControllerPinch? _controller;
|
||||
String? _error;
|
||||
List<Size> _pageSizes = const [];
|
||||
int _pageCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,9 +65,21 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
||||
await widget.session.service!.fetchBytes(widget.item.path),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
final document = await PdfDocument.openData(bytes);
|
||||
final sizes = <Size>[];
|
||||
for (var i = 1; i <= document.pagesCount && i <= _sizedPages; i++) {
|
||||
// autoCloseAndroid closes the previous page itself; closing here too throws.
|
||||
final page = await document.getPage(i, autoCloseAndroid: true);
|
||||
sizes.add(Size(page.width, page.height));
|
||||
}
|
||||
if (!mounted) {
|
||||
await document.close();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_controller = PdfControllerPinch(document: PdfDocument.openData(bytes));
|
||||
_pageSizes = sizes;
|
||||
_pageCount = document.pagesCount;
|
||||
_controller = PdfControllerPinch(document: Future.value(document));
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e.toString());
|
||||
@@ -91,6 +113,44 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
||||
// without ever pinching to zoom. There's no way to override that
|
||||
// margin from here - it isn't an exposed parameter - so this avoids
|
||||
// the branch that sets it instead of fighting it.
|
||||
return PdfViewPinch(controller: _controller!);
|
||||
//
|
||||
// pdfx's InteractiveViewer also refuses to zoom out past
|
||||
// `viewport.height / document.height`. When the whole document is
|
||||
// shorter than the screen (e.g. a one-page PDF) that ratio is above 1, so
|
||||
// after pinching in, fit-width (scale 1) becomes unreachable. PdfViewPinch
|
||||
// wraps its content in a SafeArea, which counts MediaQuery padding toward
|
||||
// that document height, so padding the bottom up to the viewport height
|
||||
// keeps the ratio at or below 1 without shrinking the gesture area.
|
||||
return AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOutCubic,
|
||||
padding: EdgeInsets.only(top: widget.topInset),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final media = MediaQuery.of(context);
|
||||
var padding = media.padding.copyWith(top: 0);
|
||||
if (_pageSizes.isNotEmpty && _pageSizes.length == _pageCount) {
|
||||
const pagePadding = 10.0; // PdfViewPinch's default `padding`
|
||||
final maxWidth = _pageSizes.fold<double>(
|
||||
0,
|
||||
(m, s) => s.width > m ? s.width : m,
|
||||
);
|
||||
final ratio = (constraints.maxWidth - pagePadding * 2) / maxWidth;
|
||||
final docHeight = _pageSizes.fold<double>(
|
||||
pagePadding,
|
||||
(h, s) => h + s.height * ratio + pagePadding,
|
||||
);
|
||||
final needed = constraints.maxHeight - docHeight;
|
||||
if (needed > padding.bottom) {
|
||||
padding = padding.copyWith(bottom: needed);
|
||||
}
|
||||
}
|
||||
return MediaQuery(
|
||||
data: media.copyWith(padding: padding),
|
||||
child: PdfViewPinch(controller: _controller!),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+3
|
||||
version: 1.0.2+5
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
Reference in New Issue
Block a user