Add Trash/Shares/Recent tabs, file sharing, Details sheet, and rebrand to Noo

Major additions since the initial rebuild:
- New tabs: Trash, Shares, Recent, configurable tab visibility/order/default
- Nextcloud sharing: search users/teams, internal link, email shares,
  public links, "others with access" - in its own Share bottom sheet
- Details bottom sheet (Info/Versions/Activity) with a persistent peek
  panel in the media viewer, reachable from selection, swipe, and the
  media viewer
- Files/Photos: tap-and-hold multi-select with a sticky selection toolbar,
  swipe actions (favorite/delete/share), sticky sort/filter controls,
  list/grid views, breadcrumb chips, whole-account Photos view
- Media viewer: pinch/double-tap zoom on images, predictive-back support,
  fixed PDF zoom-out floor
- Settings: Material You theming incl. AMOLED black, configurable bottom
  bar tabs, swipe actions, media seek bar style, and folder-listing cache
  policy (never/periodic/manual) to cut down on refetching
- Login flow moved to an in-app Chrome Custom Tab with retry-tolerant
  polling
- Replaced ad hoc widgets with native Material 3 equivalents (toggle
  IconButtons, SliverAppBar selection toolbar, SearchBar, drag handles)
- Rebrand: app renamed to Noo throughout, package id -> dev.ayushya.noo,
  new adaptive launcher icon

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 22:44:42 -04:00
co-authored by Claude Sonnet 5
parent f62d912ef7
commit d68cc43368
75 changed files with 6491 additions and 1213 deletions
+406 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../providers/server_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/frosted_glass_container.dart';
@@ -22,7 +23,7 @@ class AccountView extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Account & Storage'),
title: const Text('Settings'),
actions: [
IconButton(
onPressed: () => _showLogoutConfirmation(context, provider),
@@ -316,6 +317,16 @@ class AccountView extends StatelessWidget {
provider.setThemeMode(set.first),
),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('AMOLED Black'),
subtitle: const Text(
'Use pure black backgrounds in dark mode',
),
value: provider.amoledDark,
onChanged: provider.setAmoledDark,
),
],
),
),
@@ -331,6 +342,78 @@ class AccountView extends StatelessWidget {
),
const SizedBox(height: 10),
const _BottomBarAppearanceCard(),
const SizedBox(height: 24),
// Bottom Nav Tabs
Text(
'Bottom Bar Tabs',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Drag to reorder, tap the star to set the default, toggle to show or hide',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
const _TabSettingsCard(),
const SizedBox(height: 24),
// Swipe Actions
Text(
'Files Swipe Actions',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Choose what swiping a file left or right does in list view',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
const _SwipeActionsCard(),
const SizedBox(height: 24),
// Media Player
Text(
'Media Player',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Choose the seek bar style used when playing videos',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
const _MediaPlayerCard(),
const SizedBox(height: 24),
// Files Cache
Text(
'Files Cache',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Reuse a folder\'s listing instead of refetching it on every visit',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
const _CacheSettingsCard(),
],
),
),
@@ -575,3 +658,325 @@ class _AccentSwatch extends StatelessWidget {
);
}
}
class _SwipeActionsCard extends StatelessWidget {
const _SwipeActionsCard();
String _label(SwipeAction action) {
switch (action) {
case SwipeAction.none:
return 'None';
case SwipeAction.favorite:
return 'Favorite';
case SwipeAction.delete:
return 'Delete';
case SwipeAction.share:
return 'Share';
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final provider = context.watch<ServerProvider>();
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
children: [
_buildRow(
context,
theme,
label: 'Swipe right',
value: provider.swipeRightAction,
onChanged: provider.setSwipeRightAction,
),
const Divider(height: 1),
_buildRow(
context,
theme,
label: 'Swipe left',
value: provider.swipeLeftAction,
onChanged: provider.setSwipeLeftAction,
),
],
),
),
);
}
Widget _buildRow(
BuildContext context,
ThemeData theme, {
required String label,
required SwipeAction value,
required ValueChanged<SwipeAction> onChanged,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
DropdownButton<SwipeAction>(
value: value,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(12),
items: SwipeAction.values
.map((a) => DropdownMenuItem(value: a, child: Text(_label(a))))
.toList(),
onChanged: (a) {
if (a != null) onChanged(a);
},
),
],
),
);
}
}
class _MediaPlayerCard extends StatelessWidget {
const _MediaPlayerCard();
String _label(MediaProgressBarStyle style) {
switch (style) {
case MediaProgressBarStyle.wavy:
return 'Wavy';
case MediaProgressBarStyle.classic:
return 'Classic';
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final provider = context.watch<ServerProvider>();
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
'Seek bar style',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
DropdownButton<MediaProgressBarStyle>(
value: provider.mediaProgressBarStyle,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(12),
items: MediaProgressBarStyle.values
.map(
(s) => DropdownMenuItem(value: s, child: Text(_label(s))),
)
.toList(),
onChanged: (s) {
if (s != null) provider.setMediaProgressBarStyle(s);
},
),
],
),
),
);
}
}
class _CacheSettingsCard extends StatelessWidget {
const _CacheSettingsCard();
String _label(CachePolicy policy) {
switch (policy) {
case CachePolicy.never:
return 'Never cache';
case CachePolicy.interval:
return 'Refresh periodically';
case CachePolicy.manual:
return 'Refresh manually only';
}
}
String _description(CachePolicy policy) {
switch (policy) {
case CachePolicy.never:
return 'Every visit to a folder fetches it fresh (current behavior)';
case CachePolicy.interval:
return 'Reuse a folder\'s listing until it\'s a few minutes old';
case CachePolicy.manual:
return 'Reuse a folder\'s listing until you pull to refresh';
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children: [
RadioGroup<CachePolicy>(
groupValue: provider.cachePolicy,
onChanged: (value) {
if (value != null) provider.setCachePolicy(value);
},
child: Column(
children: [
for (final policy in CachePolicy.values)
RadioListTile<CachePolicy>(
value: policy,
title: Text(_label(policy)),
subtitle: Text(_description(policy)),
),
],
),
),
if (provider.cachePolicy == CachePolicy.interval)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Refresh interval',
style: theme.textTheme.bodyMedium,
),
Text(
'${provider.cacheIntervalMinutes} min',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
Slider(
value: provider.cacheIntervalMinutes.toDouble(),
min: 1,
max: 60,
divisions: 59,
label: '${provider.cacheIntervalMinutes} min',
onChanged: (value) =>
provider.setCacheIntervalMinutes(value.round()),
),
],
),
),
],
),
),
);
}
}
class _TabSettingsCard extends StatelessWidget {
const _TabSettingsCard();
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
return Card(
clipBehavior: Clip.antiAlias,
child: ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
onReorderItem: (oldIndex, newIndex) {
final order = List<AppTab>.from(provider.tabOrder);
final tab = order.removeAt(oldIndex);
order.insert(newIndex, tab);
provider.setTabOrder(order);
},
children: [
for (final tab in provider.tabOrder)
_TabConfigRow(
key: ValueKey(tab),
tab: tab,
isVisible: !provider.hiddenTabs.contains(tab),
isDefault: provider.defaultTab == tab,
onVisibilityChanged: (value) {
final error = provider.setTabHidden(tab, !value);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error),
behavior: SnackBarBehavior.floating,
),
);
}
},
onSetDefault: () => provider.setDefaultTab(tab),
),
],
),
);
}
}
class _TabConfigRow extends StatelessWidget {
final AppTab tab;
final bool isVisible;
final bool isDefault;
final ValueChanged<bool> onVisibilityChanged;
final VoidCallback onSetDefault;
const _TabConfigRow({
required super.key,
required this.tab,
required this.isVisible,
required this.isDefault,
required this.onVisibilityChanged,
required this.onSetDefault,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(
tab.icon,
color: isVisible
? colorScheme.onSurfaceVariant
: colorScheme.outlineVariant,
),
title: Text(
tab.label,
style: TextStyle(color: isVisible ? null : colorScheme.outline),
),
subtitle: isDefault ? const Text('Default tab') : null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
isDefault ? Icons.star_rounded : Icons.star_outline_rounded,
color: isDefault
? Colors.amber.shade700
: colorScheme.outlineVariant,
),
tooltip: 'Set as default tab',
onPressed: isVisible && !isDefault ? onSetDefault : null,
),
Switch(value: isVisible, onChanged: onVisibilityChanged),
],
),
);
}
}
+95 -96
View File
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../providers/server_provider.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
class ActivityView extends StatelessWidget {
final ScrollController scrollController;
@@ -16,77 +18,65 @@ class ActivityView extends StatelessWidget {
final provider = context.watch<ServerProvider>();
final activities = provider.activities;
return CustomScrollView(
controller: scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: kToolbarHeight,
collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface,
surfaceTintColor: colorScheme.surface,
scrolledUnderElevation: 0,
actions: const [ProfileAvatarButton()],
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (activities.isEmpty)
SliverFillRemaining(
child: Center(
child: Text(
'No recent activity',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
if (activities.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'No recent activity',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final act = activities[index];
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final act = activities[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: 0.2,
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
act.icon,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
act.icon,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
act.title,
style: theme.textTheme.titleMedium
?.copyWith(
@@ -94,45 +84,54 @@ class ActivityView extends StatelessWidget {
fontSize: 14,
),
),
Text(
DateFormat.jm().format(act.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
const SizedBox(width: 8),
Text(
DateFormat.jm().format(act.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
],
),
const SizedBox(height: 4),
Text(
act.subject,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
],
),
const SizedBox(height: 4),
Text(
act.subject,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
const SizedBox(height: 6),
Text(
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
),
const SizedBox(height: 6),
Text(
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
],
),
),
],
),
],
),
),
],
),
);
}, childCount: activities.length),
),
),
);
}, childCount: activities.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return SyncedHeaderScaffold(
scrollController: scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
);
}
}
+191 -65
View File
@@ -8,11 +8,13 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:pdfx/pdfx.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:video_player/video_player.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/details/peek_details_sheet.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/share_sheet.dart';
const _textPreviewExtensions = {
'.txt',
@@ -55,15 +57,15 @@ class FileViewerScreen extends StatefulWidget {
/// Opens the viewer with no page transition — media should appear
/// instantly, not fade/zoom in the way MaterialPageRoute normally would.
/// Going *back* still uses the theme's normal (predictive-back-capable)
/// transition, via [_InstantOpenPageRoute] below.
static Route<void> route({
required NextcloudItem item,
List<NextcloudItem>? siblings,
}) {
return PageRouteBuilder<void>(
pageBuilder: (context, animation, secondaryAnimation) =>
return _InstantOpenPageRoute<void>(
pageBuilder: (context) =>
FileViewerScreen(item: item, siblings: siblings),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
);
}
@@ -71,6 +73,30 @@ class FileViewerScreen extends StatefulWidget {
State<FileViewerScreen> createState() => _FileViewerScreenState();
}
/// A [PageRoute] that opens instantly (zero-duration forward transition)
/// but still delegates to the app's [PageTransitionsTheme] for the reverse
/// transition — unlike a plain [PageRouteBuilder] with no transitionsBuilder,
/// which never consults the theme and so can't participate in Android's
/// predictive-back gesture at all.
class _InstantOpenPageRoute<T> extends PageRoute<T>
with MaterialRouteTransitionMixin<T> {
_InstantOpenPageRoute({required this.pageBuilder, super.settings});
final WidgetBuilder pageBuilder;
@override
Widget buildContent(BuildContext context) => pageBuilder(context);
@override
bool get maintainState => true;
@override
Duration get transitionDuration => Duration.zero;
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 300);
}
class _FileViewerScreenState extends State<FileViewerScreen> {
double? _downloadProgress;
bool _isBusy = false;
@@ -190,23 +216,6 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
}
}
Future<void> _shareCurrentItem(ServerProvider provider) async {
final item = _currentItem;
setState(() => _isBusy = true);
try {
final path = await _downloadToTemp(provider, item);
await SharePlus.instance.share(ShareParams(files: [XFile(path)]));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not share ${item.name}: $e')),
);
}
} finally {
if (mounted) setState(() => _isBusy = false);
}
}
Future<void> _toggleFavorite(ServerProvider provider) async {
final item = _currentItem;
await provider.toggleItemFavorite(item);
@@ -328,28 +337,48 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: AnimatedSlide(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: _MediaActionBar(
isFavorite: _currentItem.isFavorite,
isBusy: _isBusy,
opacity: provider.bottomBarOpacity,
blurSigma: provider.bottomBarBlur,
onShare: () => _shareCurrentItem(provider),
onFavorite: () => _toggleFavorite(provider),
onDelete: () => _deleteCurrentItem(provider),
onOpenExternally: () => _openExternally(provider),
onDownload: () => _downloadToDevice(provider),
// The action bar floats just above the details peek below it,
// rather than overlapping it.
Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.sizeOf(context).height * 0.16,
),
child: Align(
alignment: Alignment.bottomCenter,
child: AnimatedSlide(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: _MediaActionBar(
isFavorite: _currentItem.isFavorite,
isBusy: _isBusy,
opacity: provider.bottomBarOpacity,
blurSigma: provider.bottomBarBlur,
onShare: () => ShareSheet.show(context, _currentItem),
onFavorite: () => _toggleFavorite(provider),
onDelete: () => _deleteCurrentItem(provider),
onOpenExternally: () => _openExternally(provider),
onDownload: () => _downloadToDevice(provider),
onDetails: () => DetailsSheet.show(context, _currentItem),
),
),
),
),
),
// Always-present peek of the current item's details, draggable
// up into the full Info/Versions/Activity sheet.
Positioned.fill(
child: IgnorePointer(
ignoring: !_controlsVisible,
child: AnimatedOpacity(
opacity: _controlsVisible ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: PeekDetailsSheet(item: _currentItem),
),
),
),
],
),
),
@@ -404,6 +433,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
item: widget.item,
isBusy: _isBusy,
onOpenExternally: () => _openExternally(provider),
onOpenDetails: () => DetailsSheet.show(context, widget.item),
);
}
}
@@ -421,6 +451,7 @@ class _MediaActionBar extends StatelessWidget {
final VoidCallback onDelete;
final VoidCallback onOpenExternally;
final VoidCallback onDownload;
final VoidCallback onDetails;
const _MediaActionBar({
required this.isFavorite,
@@ -432,6 +463,7 @@ class _MediaActionBar extends StatelessWidget {
required this.onDelete,
required this.onOpenExternally,
required this.onDownload,
required this.onDetails,
});
@override
@@ -458,10 +490,10 @@ class _MediaActionBar extends StatelessWidget {
),
_ActionIconButton(
icon: isFavorite
? Icons.star_rounded
: Icons.star_outline_rounded,
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
tooltip: 'Favorite',
color: isFavorite ? Colors.amber : null,
color: isFavorite ? Colors.red : null,
onTap: onFavorite,
),
_ActionIconButton(
@@ -480,6 +512,11 @@ class _MediaActionBar extends StatelessWidget {
color: colorScheme.error,
onTap: isBusy ? null : onDelete,
),
_ActionIconButton(
icon: Icons.info_outline_rounded,
tooltip: 'Details',
onTap: onDetails,
),
],
),
),
@@ -524,35 +561,102 @@ class _ActionIconButton extends StatelessWidget {
}
}
class _ImagePreview extends StatelessWidget {
class _ImagePreview extends StatefulWidget {
final String url;
final Map<String, String> headers;
const _ImagePreview({super.key, required this.url, required this.headers});
@override
State<_ImagePreview> createState() => _ImagePreviewState();
}
class _ImagePreviewState extends State<_ImagePreview>
with SingleTickerProviderStateMixin {
static const _doubleTapScale = 3.0;
final TransformationController _transformController =
TransformationController();
late final AnimationController _animController;
Animation<Matrix4>? _animation;
Offset _doubleTapPosition = Offset.zero;
@override
void initState() {
super.initState();
_animController =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
)..addListener(() {
if (_animation != null) {
_transformController.value = _animation!.value;
}
});
}
@override
void dispose() {
_animController.dispose();
_transformController.dispose();
super.dispose();
}
void _onDoubleTapDown(TapDownDetails details) {
_doubleTapPosition = details.localPosition;
}
void _onDoubleTap() {
final isZoomedIn = _transformController.value.getMaxScaleOnAxis() > 1.01;
final Matrix4 endMatrix;
if (isZoomedIn) {
endMatrix = Matrix4.identity();
} else {
final p = _doubleTapPosition;
endMatrix = Matrix4.identity()
..translateByDouble(
-p.dx * (_doubleTapScale - 1),
-p.dy * (_doubleTapScale - 1),
0,
1,
)
..scaleByDouble(_doubleTapScale, _doubleTapScale, _doubleTapScale, 1);
}
_animation = Matrix4Tween(
begin: _transformController.value,
end: endMatrix,
).animate(CurveTween(curve: Curves.easeOut).animate(_animController));
_animController.forward(from: 0);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox.expand(
child: InteractiveViewer(
minScale: 0.8,
maxScale: 5.0,
child: Center(
child: Image.network(
url,
headers: headers,
fit: BoxFit.contain,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
},
errorBuilder: (context, error, stack) => Center(
child: Icon(
Icons.broken_image_outlined,
color: colorScheme.onSurfaceVariant,
size: 64,
child: GestureDetector(
onDoubleTapDown: _onDoubleTapDown,
onDoubleTap: _onDoubleTap,
child: InteractiveViewer(
transformationController: _transformController,
minScale: 0.8,
maxScale: 5.0,
child: Center(
child: Image.network(
widget.url,
headers: widget.headers,
fit: BoxFit.contain,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
},
errorBuilder: (context, error, stack) => Center(
child: Icon(
Icons.broken_image_outlined,
color: colorScheme.onSurfaceVariant,
size: 64,
),
),
),
),
@@ -647,6 +751,9 @@ class _VideoPreviewState extends State<_VideoPreview> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final progressBarStyle = context
.watch<ServerProvider>()
.mediaProgressBarStyle;
if (_error != null) {
return Center(
child: Text(
@@ -737,6 +844,7 @@ class _VideoPreviewState extends State<_VideoPreview> {
controller: controller,
playedColor: fg,
trackColor: fg.withValues(alpha: 0.3),
style: progressBarStyle,
),
),
],
@@ -760,11 +868,13 @@ class _WavySeekBar extends StatefulWidget {
final VideoPlayerController controller;
final Color playedColor;
final Color trackColor;
final MediaProgressBarStyle style;
const _WavySeekBar({
required this.controller,
required this.playedColor,
required this.trackColor,
required this.style,
});
@override
@@ -793,7 +903,8 @@ class _WavySeekBarState extends State<_WavySeekBar>
@override
Widget build(BuildContext context) {
final value = widget.controller.value;
final isPlaying = value.isPlaying && _dragRatio == null;
final isWavy = widget.style == MediaProgressBarStyle.wavy;
final isPlaying = isWavy && value.isPlaying && _dragRatio == null;
if (isPlaying && !_waveController.isAnimating) {
_waveController.repeat();
} else if (!isPlaying && _waveController.isAnimating) {
@@ -976,7 +1087,14 @@ class _PdfPreviewState extends State<_PdfPreview> {
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
return PdfViewPinch(controller: _controller!);
// pdfx's own default minScale (1.0 = the page's true/100% size) is
// often *larger* than the fit-to-width size a wide page first renders
// at, since that initial render is just normal box layout, not the
// InteractiveViewer transform pinching engages on first touch - so the
// moment you touch the page it snaps up to 1.0 and, with the default
// floor, can never pinch back down past it. A low floor here lets you
// zoom back out past that to the fit-width view you started at.
return PdfViewPinch(controller: _controller!, minScale: 0.3);
}
}
@@ -1049,11 +1167,13 @@ class _UnsupportedPreview extends StatelessWidget {
final NextcloudItem item;
final bool isBusy;
final VoidCallback onOpenExternally;
final VoidCallback onOpenDetails;
const _UnsupportedPreview({
required this.item,
required this.isBusy,
required this.onOpenExternally,
required this.onOpenDetails,
});
@override
@@ -1094,6 +1214,12 @@ class _UnsupportedPreview extends StatelessWidget {
icon: const Icon(Icons.open_in_new_rounded),
label: const Text('Open with...'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onOpenDetails,
icon: const Icon(Icons.info_outline_rounded),
label: const Text('Details'),
),
],
),
),
+586 -785
View File
File diff suppressed because it is too large Load Diff
+497 -97
View File
@@ -1,78 +1,314 @@
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/selection_action.dart';
import '../providers/server_provider.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/selectable_thumbnail.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
class PhotosView extends StatelessWidget {
class PhotosView extends StatefulWidget {
final ScrollController scrollController;
const PhotosView({super.key, required this.scrollController});
@override
State<PhotosView> createState() => _PhotosViewState();
}
class _PhotosViewState extends State<PhotosView> {
bool _requested = false;
final Set<String> _selectedIds = {};
bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick();
setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
});
}
void _clearSelection() {
setState(_selectedIds.clear);
}
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items.
List<SelectionAction> _buildSelectionActions(
ServerProvider provider,
List<NextcloudItem> selected,
) {
return [
SelectionAction(
icon: selected.every((i) => i.isFavorite)
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
label: selected.every((i) => i.isFavorite)
? 'Remove from favorites'
: 'Favorite',
color: Colors.red.shade400,
onTap: () => _favoriteSelected(provider, selected),
),
SelectionAction(
icon: Icons.share_rounded,
label: 'Share',
onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single)
: _shareSelected(context, provider, selected),
),
SelectionAction(
icon: Icons.download_rounded,
label: 'Download',
onTap: () => _downloadSelected(context, provider, selected),
),
SelectionAction(
icon: Icons.delete_outline_rounded,
label: 'Delete',
color: Theme.of(context).colorScheme.error,
onTap: () => _confirmDeleteSelected(context, provider, selected),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.info_outline_rounded,
label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single),
),
];
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final photos = provider.photoItems;
return CustomScrollView(
controller: scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchAllMedia(),
);
}
final photos = provider.photoItems;
final selectedItems = photos
.where((i) => _selectedIds.contains(i.id))
.toList();
final controlsRow = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: SizedBox(
height: 44,
child: Row(
children: [
IconButton(
icon: Icon(
provider.sortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: provider.sortAscending ? 'Ascending' : 'Descending',
onPressed: provider.toggleSortOrder,
),
Expanded(
child: SortMenuButton(
field: provider.sortField,
onChanged: provider.setSortField,
),
),
ToggleIconButton(
icon: provider.showFavoritesOnlyPhotos
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
isSelected: provider.showFavoritesOnlyPhotos,
onTap: provider.toggleFavoritesFilterPhotos,
tooltip: 'Favorites only',
),
const SizedBox(width: 4),
ToggleIconButton(
icon: provider.showHiddenPhotos
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenPhotos,
onTap: provider.toggleShowHiddenPhotos,
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud,
onTap: () => provider.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: provider.storageScope == StorageScope.external,
onTap: () => provider.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
],
),
),
);
final List<Widget> contentSlivers = [
// Sticky while browsing; once selecting, the toolbar below takes over
// the pinned slot and this is free to scroll away.
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(height: 60, child: controlsRow),
),
if (_isSelecting)
SliverAppBar(
pinned: true,
expandedHeight: kToolbarHeight,
collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface,
surfaceTintColor: colorScheme.surface,
scrolledUnderElevation: 0,
actions: const [ProfileAvatarButton()],
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
),
title: Text('${selectedItems.length} selected'),
actions: [
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
IconButton(
icon: Icon(action.icon, color: action.color),
tooltip: action.label,
onPressed: action.onTap,
),
const SizedBox(width: 4),
],
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (photos.isEmpty)
SliverFillRemaining(
child: Center(
if (provider.isMediaLoading && photos.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.mediaErrorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.photo_library_outlined,
Icons.error_outline_rounded,
size: 64,
color: colorScheme.outlineVariant,
color: colorScheme.error,
),
const SizedBox(height: 12),
const SizedBox(height: 16),
Text(
'No photos found in Nextcloud library',
style: theme.textTheme.titleMedium?.copyWith(
'Could not load photos',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
provider.mediaErrorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchAllMedia,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.0,
),
delegate: SliverChildBuilderDelegate((context, index) {
final photo = photos[index];
return _buildPhotoTile(context, photo, provider);
}, childCount: photos.length),
),
)
else if (photos.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.photo_library_outlined,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'No photos found in Nextcloud library',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.0,
),
delegate: SliverChildBuilderDelegate((context, index) {
final photo = photos[index];
return _buildPhotoTile(context, photo, provider);
}, childCount: photos.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
// Fixed clearance so the last row isn't hidden behind the floating
// nav bar, regardless of grid length.
const SliverToBoxAdapter(child: SizedBox(height: 100)),
// For a short grid this stretches the white card's background down to
// the screen edge (matching the empty state above); for a long grid
// that already fills the viewport it contributes nothing extra.
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return PopScope(
canPop: !_isSelecting,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchAllMedia,
contentSlivers: contentSlivers,
),
);
}
@@ -81,71 +317,87 @@ class PhotosView extends StatelessWidget {
NextcloudItem photo,
ServerProvider provider,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colorScheme = Theme.of(context).colorScheme;
final isSelected = _selectedIds.contains(photo.id);
return GestureDetector(
onTap: () => _openLightbox(context, photo, provider),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
photo.previewUrl != null
? LayoutBuilder(
builder: (context, constraints) {
// Decode at the tile's actual rendered size rather
// than the full 500x500 preview the server returns —
// cheaper to decode/cache while scrolling a grid.
final cachePixels =
(constraints.maxWidth *
MediaQuery.of(context).devicePixelRatio)
.round();
return Image.network(
photo.previewUrl!,
headers: provider.service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) =>
_buildFallbackTile(context, photo),
);
},
)
: _buildFallbackTile(context, photo),
if (photo.type == NextcloudItemType.video)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 16,
// No border radius on the tile itself — the outer ClipRRect below is
// the only place that rounds it, so a swipe doesn't reveal a stray
// floating rounded corner mid-drag (see the same fix in files_view).
final tile = GestureDetector(
onTap: () {
if (_isSelecting) {
_toggleSelection(photo);
} else {
_openLightbox(context, photo, provider);
}
},
onLongPress: () => _toggleSelection(photo),
child: ColoredBox(
color: colorScheme.surfaceContainerHigh,
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: Stack(
fit: StackFit.expand,
children: [
photo.previewUrl != null
? LayoutBuilder(
builder: (context, constraints) {
// Decode at the tile's actual rendered size rather
// than the full 500x500 preview the server returns —
// cheaper to decode/cache while scrolling a grid.
final cachePixels =
(constraints.maxWidth *
MediaQuery.of(context).devicePixelRatio)
.round();
return Image.network(
photo.previewUrl!,
headers: provider.service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) =>
_buildFallbackTile(context, photo),
);
},
)
: _buildFallbackTile(context, photo),
if (photo.type == NextcloudItemType.video)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 16,
),
),
),
),
if (photo.isFavorite)
const Positioned(
top: 8,
right: 8,
child: Icon(Icons.star_rounded, color: Colors.amber, size: 18),
),
],
if (photo.isFavorite)
const Positioned(
top: 8,
right: 8,
child: Icon(
Icons.favorite_rounded,
color: Colors.red,
size: 18,
),
),
],
),
),
),
);
return ClipRRect(borderRadius: BorderRadius.circular(16), child: tile);
}
Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) {
@@ -174,4 +426,152 @@ class PhotosView extends StatelessWidget {
FileViewerScreen.route(item: photo, siblings: provider.photoItems),
);
}
Future<void> _favoriteSelected(
ServerProvider provider,
List<NextcloudItem> items,
) async {
final allFavorited = items.every((i) => i.isFavorite);
for (final item in items) {
if (item.isFavorite == allFavorited) {
await provider.toggleItemFavorite(item);
}
}
_clearSelection();
}
Future<void> _shareSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
content: Text('Creating share link(s)…'),
behavior: SnackBarBehavior.floating,
),
);
final lines = <String>[];
for (final item in items) {
final link = await provider.createShareLink(item);
if (link != null) {
lines.add(items.length > 1 ? '${item.name}: $link' : link);
}
}
if (!mounted) return;
messenger.hideCurrentSnackBar();
_clearSelection();
if (lines.isEmpty) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create share link(s)'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
try {
await SharePlus.instance.share(ShareParams(text: lines.join('\n')));
} catch (_) {
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Could not open the share sheet'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _downloadSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(
content: Text('Downloading ${items.length} item(s)...'),
behavior: SnackBarBehavior.floating,
),
);
var succeeded = 0;
for (final item in items) {
try {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, item.name);
await provider.service!.downloadToFile(item.path, tempPath);
final ext = p.extension(item.name).replaceFirst('.', '');
final baseName = p.basenameWithoutExtension(item.name);
await FileSaver.instance.saveFile(
name: baseName,
filePath: tempPath,
ext: ext,
);
succeeded++;
} catch (_) {
// Reported in the summary snackbar below.
}
}
_clearSelection();
messenger.showSnackBar(
SnackBar(
content: Text('Downloaded $succeeded of ${items.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _confirmDeleteSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete Items'),
content: Text(
'Delete ${items.length} item(s) from the server? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
var succeeded = 0;
for (final item in items) {
final success = await provider.deleteItem(item.path);
if (success) succeeded++;
}
_clearSelection();
messenger.showSnackBar(
SnackBar(
content: Text('Deleted $succeeded of ${items.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
}
}
+262
View File
@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
IconData _recentItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
Color _recentItemIconColor(BuildContext context, NextcloudItemType type) {
final colorScheme = Theme.of(context).colorScheme;
switch (type) {
case NextcloudItemType.folder:
return colorScheme.primary;
case NextcloudItemType.image:
return Colors.amber.shade700;
case NextcloudItemType.video:
return Colors.deepOrange.shade600;
case NextcloudItemType.audio:
return Colors.purple.shade600;
case NextcloudItemType.document:
return Colors.blue.shade700;
case NextcloudItemType.archive:
return Colors.teal.shade700;
case NextcloudItemType.file:
return colorScheme.outline;
}
}
/// Recently modified files across the whole account (not folders), newest
/// first — see [ServerProvider.fetchRecent].
class RecentView extends StatefulWidget {
final ScrollController scrollController;
const RecentView({super.key, required this.scrollController});
@override
State<RecentView> createState() => _RecentViewState();
}
class _RecentViewState extends State<RecentView> {
bool _requested = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchRecent(),
);
}
final items = provider.recentItems;
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (provider.isRecentLoading && items.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.recentErrorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load recent files',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
provider.recentErrorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchRecent,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'No recent files',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
return _buildTile(context, item, provider);
}, childCount: items.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchRecent,
contentSlivers: contentSlivers,
);
}
Widget _buildTile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = _recentItemIconColor(context, item.type);
final folderPath = item.path.substring(
0,
item.path.length - item.name.length,
);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => _openFile(context, item, provider),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_recentItemIcon(item.type),
color: iconColor,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
folderPath.isEmpty ? '/' : folderPath,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
DateFormat.yMMMd().add_jm().format(item.lastModified),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
),
),
);
}
void _openFile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
final siblings = provider.recentItems.where((i) => i.isMedia).toList();
Navigator.push(
context,
FileViewerScreen.route(item: item, siblings: siblings),
);
}
}
+29 -30
View File
@@ -23,14 +23,6 @@ class _SearchViewState extends State<SearchView> {
String? _error;
bool _hasSearched = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _focusNode.requestFocus(),
);
}
@override
void dispose() {
_debounce?.cancel();
@@ -104,30 +96,37 @@ class _SearchViewState extends State<SearchView> {
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: TextField(
controller: _controller,
focusNode: _focusNode,
onChanged: _onChanged,
decoration: const InputDecoration(
hintText: 'Search your files...',
border: InputBorder.none,
),
style: theme.textTheme.titleMedium,
),
actions: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_controller.clear();
_onChanged('');
},
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SearchBar(
controller: _controller,
focusNode: _focusNode,
autoFocus: true,
hintText: 'Search your files...',
onChanged: _onChanged,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
),
trailing: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_controller.clear();
_onChanged('');
},
),
],
),
),
],
Expanded(child: _buildBody(context, colorScheme, theme)),
],
),
),
body: _buildBody(context, colorScheme, theme),
);
}
+318
View File
@@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../providers/server_provider.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
IconData _shareItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
IconData _shareTypeIcon(ShareType type) {
switch (type) {
case ShareType.user:
return Icons.person_rounded;
case ShareType.group:
return Icons.groups_rounded;
case ShareType.publicLink:
return Icons.link_rounded;
case ShareType.email:
return Icons.email_rounded;
case ShareType.federated:
return Icons.public_rounded;
case ShareType.other:
return Icons.share_rounded;
}
}
class SharesView extends StatefulWidget {
final ScrollController scrollController;
const SharesView({super.key, required this.scrollController});
@override
State<SharesView> createState() => _SharesViewState();
}
class _SharesViewState extends State<SharesView> {
bool _requested = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchShares(),
);
}
final shares = provider.shares;
final List<Widget> contentSlivers = [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('Shared by me')),
ButtonSegment(value: true, label: Text('Shared with me')),
],
selected: {provider.sharesWithMe},
onSelectionChanged: (set) => provider.setSharesWithMe(set.first),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isSharesLoading && shares.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.sharesErrorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load shares',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
provider.sharesErrorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchShares,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
)
else if (shares.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.groups_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
provider.sharesWithMe
? 'Nothing has been shared with you'
: 'You haven\'t shared anything yet',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final share = shares[index];
return _buildShareTile(context, share, provider);
}, childCount: shares.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchShares,
contentSlivers: contentSlivers,
);
}
Widget _buildShareTile(
BuildContext context,
NextcloudShare share,
ServerProvider provider,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = colorScheme.primary;
final withWhom = share.sharedWithMe
? 'From ${share.ownerDisplayName}'
: (share.sharedWithDisplayName != null
? 'With ${share.sharedWithDisplayName}'
: 'Shared link');
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_shareItemIcon(share.itemType),
color: iconColor,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
share.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
_shareTypeIcon(share.shareType),
size: 13,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
'$withWhom • ${DateFormat.yMMMd().format(share.sharedAt)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
IconButton(
icon: Icon(Icons.link_off_rounded, color: colorScheme.error),
tooltip: share.sharedWithMe ? 'Remove' : 'Unshare',
onPressed: () => _confirmUnshare(context, provider, share),
),
],
),
),
),
);
}
Future<void> _confirmUnshare(
BuildContext context,
ServerProvider provider,
NextcloudShare share,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(share.sharedWithMe ? 'Remove Share' : 'Unshare'),
content: Text(
share.sharedWithMe
? 'Remove "${share.name}" shared with you by ${share.ownerDisplayName}?'
: 'Stop sharing "${share.name}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteShare(share);
messenger.showSnackBar(
SnackBar(
content: Text(
success
? 'Removed share for ${share.name}'
: 'Failed to remove share for ${share.name}',
),
behavior: SnackBarBehavior.floating,
),
);
}
}
+289
View File
@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
IconData _trashItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
class TrashView extends StatefulWidget {
final ScrollController scrollController;
const TrashView({super.key, required this.scrollController});
@override
State<TrashView> createState() => _TrashViewState();
}
class _TrashViewState extends State<TrashView> {
bool _requested = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchTrash(),
);
}
final trash = provider.trashItems;
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (provider.isTrashLoading && trash.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.trashErrorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load trash',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
provider.trashErrorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchTrash,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
)
else if (trash.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.delete_outline_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'Trash is empty',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = trash[index];
return _buildTrashTile(context, item, provider);
}, childCount: trash.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchTrash,
contentSlivers: contentSlivers,
);
}
Widget _buildTrashTile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = colorScheme.outline;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_trashItemIcon(item.type),
color: iconColor,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
item.deletedAt != null
? 'Deleted ${DateFormat.yMMMd().format(item.deletedAt!)}'
: 'Deleted',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.restore_rounded),
tooltip: 'Restore',
color: colorScheme.primary,
onPressed: () => _restore(context, provider, item),
),
IconButton(
icon: Icon(
Icons.delete_forever_rounded,
color: colorScheme.error,
),
tooltip: 'Delete forever',
onPressed: () => _confirmDeleteForever(context, provider, item),
),
],
),
),
),
);
}
Future<void> _restore(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) async {
final messenger = ScaffoldMessenger.of(context);
final success = await provider.restoreTrashItem(item);
messenger.showSnackBar(
SnackBar(
content: Text(
success ? 'Restored ${item.name}' : 'Failed to restore ${item.name}',
),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _confirmDeleteForever(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete Forever'),
content: Text(
'Permanently delete "${item.name}"? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete Forever'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteTrashItemForever(item);
messenger.showSnackBar(
SnackBar(
content: Text(
success ? 'Deleted ${item.name}' : 'Failed to delete ${item.name}',
),
behavior: SnackBarBehavior.floating,
),
);
}
}