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:
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import '../views/activity_view.dart';
|
||||
import '../views/files_view.dart';
|
||||
import '../views/photos_view.dart';
|
||||
import '../views/recent_view.dart';
|
||||
import '../views/shares_view.dart';
|
||||
import '../views/trash_view.dart';
|
||||
|
||||
/// Builds the view widget for a given [AppTab]. Shared by the main shell
|
||||
/// (for visible tabs) and the "more tabs" dropdown (for launching a tab
|
||||
/// that's currently hidden from the bottom nav bar).
|
||||
Widget buildAppTabView(AppTab tab, ScrollController controller) {
|
||||
switch (tab) {
|
||||
case AppTab.files:
|
||||
return FilesView(scrollController: controller);
|
||||
case AppTab.photos:
|
||||
return PhotosView(scrollController: controller);
|
||||
case AppTab.activity:
|
||||
return ActivityView(scrollController: controller);
|
||||
case AppTab.trash:
|
||||
return TrashView(scrollController: controller);
|
||||
case AppTab.shares:
|
||||
return SharesView(scrollController: controller);
|
||||
case AppTab.recent:
|
||||
return RecentView(scrollController: controller);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Tappable "Home / Folder / Sub-folder" trail shown whenever the user has
|
||||
/// navigated below the root, in both the Files and Photos tabs.
|
||||
class Breadcrumbs extends StatelessWidget {
|
||||
final List<String> pathStack;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
const Breadcrumbs({super.key, required this.pathStack, required this.onTap});
|
||||
|
||||
String _labelFor(int index) {
|
||||
if (index == 0) return 'Home';
|
||||
final segments = pathStack[index]
|
||||
.split('/')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
return segments.isEmpty ? 'Home' : segments.last;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final lastIndex = pathStack.length - 1;
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < pathStack.length; i++) ...[
|
||||
if (i > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
if (i == lastIndex)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
_labelFor(i),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
// A chip's whole pill is the tap target (not just the text),
|
||||
// so the previous plain-text crumbs were fiddly to hit.
|
||||
ActionChip(
|
||||
label: Text(_labelFor(i)),
|
||||
labelStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
onPressed: () => onTap(i),
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
side: BorderSide.none,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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';
|
||||
|
||||
/// Per-file activity feed — same list-item look as the account-wide
|
||||
/// Activity tab (activity_view.dart), scoped to a single item.
|
||||
class DetailsActivityTab extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const DetailsActivityTab({super.key, required this.item});
|
||||
|
||||
@override
|
||||
State<DetailsActivityTab> createState() => _DetailsActivityTabState();
|
||||
}
|
||||
|
||||
class _DetailsActivityTabState extends State<DetailsActivityTab> {
|
||||
bool _requested = false;
|
||||
bool _isLoading = true;
|
||||
List<NextcloudActivity> _activities = [];
|
||||
|
||||
Future<void> _load() async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final result = await provider.fetchFileActivity(widget.item);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_activities = result;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_requested) {
|
||||
_requested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_activities.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No recent activity',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
itemCount: _activities.length,
|
||||
itemBuilder: (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),
|
||||
),
|
||||
),
|
||||
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,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
act.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: 6),
|
||||
Text(
|
||||
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../synced_header_scaffold.dart' show formatBytes;
|
||||
|
||||
/// Plain metadata tab of the Details sheet — name, type, size, location,
|
||||
/// and dates. No network calls, so no loading state needed.
|
||||
class DetailsInfoTab extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const DetailsInfoTab({super.key, required this.item, this.scrollController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final rows = <(String, String)>[
|
||||
('Name', item.name),
|
||||
('Type', item.isFolder ? 'Folder' : (item.mimeType ?? 'File')),
|
||||
if (!item.isFolder) ('Size', formatBytes(item.size)),
|
||||
('Location', item.path),
|
||||
('Modified', DateFormat.yMMMd().add_jm().format(item.lastModified)),
|
||||
('Created', DateFormat.yMMMd().add_jm().format(item.dateCreated)),
|
||||
('Favorite', item.isFavorite ? 'Yes' : 'No'),
|
||||
];
|
||||
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final (label, value) = rows[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../gradual_bottom_sheet.dart';
|
||||
import '../synced_header_scaffold.dart' show formatBytes;
|
||||
import 'details_activity_tab.dart';
|
||||
import 'details_info_tab.dart';
|
||||
import 'details_versions_tab.dart';
|
||||
|
||||
IconData _detailsItemIcon(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable "Details" bottom sheet for a single file/folder — Info,
|
||||
/// Versions, and Activity tabs. Sharing lives in its own sheet
|
||||
/// ([ShareSheet]), triggered separately from wherever a "Share" action
|
||||
/// appears. Triggered from long-press selection (exactly one item), the
|
||||
/// media viewer's action bar, and the "open externally" flow for
|
||||
/// unsupported file types.
|
||||
class DetailsSheet extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const DetailsSheet({super.key, required this.item});
|
||||
|
||||
static Future<void> show(BuildContext context, NextcloudItem item) {
|
||||
return showGradualBottomSheet(
|
||||
context,
|
||||
builder: (context, scrollController) => DetailsSheet(item: item),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Column(
|
||||
children: [
|
||||
DetailsHeader(item: item),
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(Icons.info_outline_rounded), text: 'Info'),
|
||||
Tab(icon: Icon(Icons.history_rounded), text: 'Versions'),
|
||||
Tab(icon: Icon(Icons.electric_bolt_rounded), text: 'Activity'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
DetailsInfoTab(item: item),
|
||||
DetailsVersionsTab(item: item),
|
||||
DetailsActivityTab(item: item),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon/name/size/date row shared by [DetailsSheet] and the media viewer's
|
||||
/// collapsed "peek" state (which shows just this, before the user drags the
|
||||
/// sheet open far enough to reveal the tabs).
|
||||
class DetailsHeader extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const DetailsHeader({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
_detailsItemIcon(item.type),
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
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.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.isFolder
|
||||
? DateFormat.yMMMd().format(item.lastModified)
|
||||
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/nextcloud_file_version.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../../providers/server_provider.dart';
|
||||
import '../synced_header_scaffold.dart' show formatBytes;
|
||||
|
||||
/// File version history — a synthetic "Current version" row (from the
|
||||
/// item's own metadata) followed by whatever the DAV versions endpoint
|
||||
/// returns, each with Restore/Download actions.
|
||||
class DetailsVersionsTab extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const DetailsVersionsTab({super.key, required this.item});
|
||||
|
||||
@override
|
||||
State<DetailsVersionsTab> createState() => _DetailsVersionsTabState();
|
||||
}
|
||||
|
||||
class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
|
||||
bool _requested = false;
|
||||
bool _isLoading = true;
|
||||
List<NextcloudFileVersion> _versions = [];
|
||||
|
||||
Future<void> _load() async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final result = await provider.fetchFileVersions(widget.item);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_versions = result;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _restore(NextcloudFileVersion version) async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final success = await provider.restoreFileVersion(
|
||||
widget.item,
|
||||
version.versionLabel,
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success ? 'Restored version' : 'Could not restore version',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
if (success) _load();
|
||||
}
|
||||
|
||||
Future<void> _download(NextcloudFileVersion version) async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = p.join(
|
||||
tempDir.path,
|
||||
'${version.versionLabel}_${widget.item.name}',
|
||||
);
|
||||
final success = await provider.downloadVersion(
|
||||
widget.item,
|
||||
version.versionLabel,
|
||||
tempPath,
|
||||
);
|
||||
if (!success) throw Exception('download failed');
|
||||
final ext = p.extension(widget.item.name).replaceFirst('.', '');
|
||||
final baseName = p.basenameWithoutExtension(widget.item.name);
|
||||
await FileSaver.instance.saveFile(
|
||||
name: baseName,
|
||||
filePath: tempPath,
|
||||
ext: ext,
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Version downloaded'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not download version'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_requested) {
|
||||
_requested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final items = [
|
||||
NextcloudFileVersion(
|
||||
versionLabel: 'current',
|
||||
timestamp: widget.item.lastModified,
|
||||
size: widget.item.size,
|
||||
isCurrent: true,
|
||||
),
|
||||
..._versions,
|
||||
];
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final version = items[index];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.history_rounded,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
version.isCurrent
|
||||
? 'Current version'
|
||||
: DateFormat.yMMMd().add_jm().format(version.timestamp),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${DateFormat.yMMMd().format(version.timestamp)} • ${formatBytes(version.size)}',
|
||||
),
|
||||
trailing: version.isCurrent
|
||||
? null
|
||||
: PopupMenuButton<String>(
|
||||
onSelected: (value) => value == 'restore'
|
||||
? _restore(version)
|
||||
: _download(version),
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'restore', child: Text('Restore')),
|
||||
PopupMenuItem(value: 'download', child: Text('Download')),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import 'details_sheet.dart';
|
||||
|
||||
/// A persistent (non-modal) draggable panel that rests as a small "peek"
|
||||
/// of a file's details - just the header - so it's always partly visible
|
||||
/// without blocking the content above it, and expands into the full
|
||||
/// [DetailsSheet] (with its Info/Versions/Activity tabs) once dragged past
|
||||
/// [_expandThreshold]. Meant to be placed directly in a `Stack`, not shown
|
||||
/// via `showModalBottomSheet`/`showBottomSheet` - there's no scrim and
|
||||
/// nothing behind it is blocked from receiving gestures.
|
||||
class PeekDetailsSheet extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const PeekDetailsSheet({super.key, required this.item});
|
||||
|
||||
@override
|
||||
State<PeekDetailsSheet> createState() => _PeekDetailsSheetState();
|
||||
}
|
||||
|
||||
class _PeekDetailsSheetState extends State<PeekDetailsSheet> {
|
||||
static const _peekSize = 0.16;
|
||||
static const _maxSize = 0.9;
|
||||
static const _expandThreshold = 0.3;
|
||||
|
||||
double _size = _peekSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final expanded = _size > _expandThreshold;
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: _peekSize,
|
||||
minChildSize: _peekSize,
|
||||
maxChildSize: _maxSize,
|
||||
snap: true,
|
||||
snapSizes: const [_peekSize, _maxSize],
|
||||
builder: (context, scrollController) {
|
||||
return NotificationListener<DraggableScrollableNotification>(
|
||||
onNotification: (notification) {
|
||||
if ((notification.extent - _size).abs() > 0.002) {
|
||||
setState(() => _size = notification.extent);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: expanded
|
||||
? DetailsSheet(item: widget.item)
|
||||
// Peek state: just the header, but still wrapped in
|
||||
// a scrollable using the sheet's own controller - a
|
||||
// DraggableScrollableSheet only picks up drag-to-
|
||||
// resize gestures from a Scrollable using that
|
||||
// controller, even over otherwise-static content.
|
||||
: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: DetailsHeader(item: widget.item),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef GradualSheetBuilder =
|
||||
Widget Function(BuildContext context, ScrollController scrollController);
|
||||
|
||||
/// Shows a bottom sheet that can be dragged open/closed gradually (and
|
||||
/// snaps to a few comfortable stops on release) instead of the default
|
||||
/// modal bottom sheet's binary fully-open/fully-dismissed behavior.
|
||||
/// [builder] gets a [ScrollController] it can hand to its own scrollable
|
||||
/// content so dragging that content also resizes the sheet; content that
|
||||
/// manages its own scrolling (e.g. a TabBarView of independent lists) can
|
||||
/// just ignore it.
|
||||
Future<T?> showGradualBottomSheet<T>(
|
||||
BuildContext context, {
|
||||
required GradualSheetBuilder builder,
|
||||
double initialSize = 0.6,
|
||||
double minSize = 0.3,
|
||||
double maxSize = 0.95,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: initialSize,
|
||||
minChildSize: minSize,
|
||||
maxChildSize: maxSize,
|
||||
snap: true,
|
||||
snapSizes: [minSize, initialSize, maxSize],
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(child: builder(context, scrollController)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import 'app_tab_view_builder.dart';
|
||||
|
||||
/// Opens a tab that's currently hidden from the bottom nav bar as its own
|
||||
/// standalone screen. Shown in every tab's toolbar (not just Files) so
|
||||
/// hidden destinations stay reachable no matter which tab is active.
|
||||
/// Always visible; dims/disables itself when nothing is hidden.
|
||||
class MoreTabsButton extends StatelessWidget {
|
||||
const MoreTabsButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<ServerProvider>();
|
||||
|
||||
return PopupMenuButton<AppTab>(
|
||||
icon: const Icon(Icons.apps_rounded),
|
||||
tooltip: 'More tabs',
|
||||
enabled: provider.hiddenTabs.isNotEmpty,
|
||||
onSelected: (tab) => _openHiddenTab(context, tab),
|
||||
itemBuilder: (context) => [
|
||||
for (final tab in provider.tabOrder.where(
|
||||
(t) => provider.hiddenTabs.contains(t),
|
||||
))
|
||||
PopupMenuItem(
|
||||
value: tab,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(tab.icon, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(tab.label),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openHiddenTab(BuildContext context, AppTab tab) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
Scaffold(body: buildAppTabView(tab, ScrollController())),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class ProfileAvatarButton extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
tooltip: 'Account',
|
||||
tooltip: 'Settings',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A single icon button that fills with a solid circle when active —
|
||||
/// the building block for Drive-style toggle controls.
|
||||
/// the building block for Drive-style toggle controls. Thin wrapper around
|
||||
/// the native Material 3 toggle `IconButton` (its `isSelected` constructor
|
||||
/// param, not a hand-rolled Material+InkWell) so every call site here gets
|
||||
/// the built-in selected/unselected treatment for free.
|
||||
class ToggleIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
@@ -20,26 +23,26 @@ class ToggleIconButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final button = Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
return IconButton(
|
||||
isSelected: isSelected,
|
||||
icon: Icon(icon, size: 20),
|
||||
tooltip: tooltip,
|
||||
onPressed: onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: ButtonStyle(
|
||||
shape: const WidgetStatePropertyAll(CircleBorder()),
|
||||
foregroundColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected)
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
backgroundColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected)
|
||||
? colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return tooltip != null ? Tooltip(message: tooltip!, child: button) : button;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Wraps a thumbnail so it can show the multi-select checkmark state:
|
||||
/// dimmed with a checkmark overlaid once selected, matching the Google
|
||||
/// Photos-style long-press-to-select pattern. Shared by the Files and
|
||||
/// Photos tabs.
|
||||
class SelectableThumbnail extends StatelessWidget {
|
||||
final bool isSelected;
|
||||
final Widget child;
|
||||
|
||||
/// Fixed width/height for a small square thumbnail (Files list/grid
|
||||
/// tiles). Leave null to fill whatever space the parent already gives it
|
||||
/// instead (e.g. a Photos grid cell, whose size comes from the grid
|
||||
/// layout rather than a fixed constant).
|
||||
final double? size;
|
||||
final double checkmarkSize;
|
||||
|
||||
const SelectableThumbnail({
|
||||
super.key,
|
||||
required this.isSelected,
|
||||
required this.child,
|
||||
this.size,
|
||||
this.checkmarkSize = 28,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final stack = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedOpacity(
|
||||
opacity: isSelected ? 0.35 : 1,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Center(
|
||||
child: Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: colorScheme.primary,
|
||||
size: checkmarkSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (size == null) return stack;
|
||||
return SizedBox(width: size, height: size, child: stack);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../models/nextcloud_share.dart';
|
||||
import '../models/nextcloud_sharee.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import 'gradual_bottom_sheet.dart';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the "internal link" URL for a file, or null if [fileId] isn't a
|
||||
/// numeric Nextcloud file id (WebDAV `oc:fileid` was missing for this item).
|
||||
String? internalLinkFor(String serverUrl, String fileId) {
|
||||
if (int.tryParse(fileId) == null) return null;
|
||||
var url = serverUrl.trim();
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://$url';
|
||||
}
|
||||
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
||||
return '$url/index.php/f/$fileId';
|
||||
}
|
||||
|
||||
/// Standalone "Share" bottom sheet for a single file/folder — replicates
|
||||
/// the web client's Sharing panel: search users/teams, "Others with
|
||||
/// access" (inherited shares), an internal link to copy, an email-share
|
||||
/// field, and a public link. Every "Share" action in the app (swipe,
|
||||
/// multi-select, media viewer) opens this same sheet.
|
||||
class ShareSheet extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const ShareSheet({super.key, required this.item, this.scrollController});
|
||||
|
||||
static Future<void> show(BuildContext context, NextcloudItem item) {
|
||||
return showGradualBottomSheet(
|
||||
context,
|
||||
builder: (context, scrollController) =>
|
||||
ShareSheet(item: item, scrollController: scrollController),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ShareSheet> createState() => _ShareSheetState();
|
||||
}
|
||||
|
||||
class _ShareSheetState extends State<ShareSheet> {
|
||||
bool _requested = false;
|
||||
bool _isLoading = true;
|
||||
List<NextcloudShare> _shares = [];
|
||||
List<NextcloudShare> _inherited = [];
|
||||
List<NextcloudSharee> _searchResults = [];
|
||||
bool _isSearching = false;
|
||||
final _searchController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final results = await Future.wait([
|
||||
provider.fetchItemShares(widget.item),
|
||||
provider.fetchInheritedShares(widget.item),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_shares = results[0];
|
||||
_inherited = results[1];
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _search(String query) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
setState(() => _searchResults = []);
|
||||
return;
|
||||
}
|
||||
setState(() => _isSearching = true);
|
||||
final provider = context.read<ServerProvider>();
|
||||
final results = await provider.searchSharees(trimmed);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addSharee(NextcloudSharee sharee) async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final share = await provider.createShare(
|
||||
path: widget.item.path,
|
||||
shareType: sharee.shareTypeValue,
|
||||
shareWith: sharee.shareWith,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (share == null) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not share this item'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_shares = [..._shares, share];
|
||||
_searchResults = [];
|
||||
_searchController.clear();
|
||||
});
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Shared with ${sharee.label}'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addEmailShare() async {
|
||||
final email = _emailController.text.trim();
|
||||
if (email.isEmpty) return;
|
||||
final provider = context.read<ServerProvider>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final share = await provider.createShare(
|
||||
path: widget.item.path,
|
||||
shareType: 4,
|
||||
shareWith: email,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (share == null) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not create email share'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_shares = [..._shares, share];
|
||||
_emailController.clear();
|
||||
});
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Shared with $email'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createPublicLink() async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final share = await provider.createShare(
|
||||
path: widget.item.path,
|
||||
shareType: 3,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (share == null) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not create public link'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _shares = [..._shares, share]);
|
||||
}
|
||||
|
||||
Future<void> _removeShare(NextcloudShare share) async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final success = await provider.deleteShare(share);
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
setState(() => _shares = _shares.where((s) => s.id != share.id).toList());
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not remove share'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _copy(String text, String label) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Copied $label'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader(String label) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _shareRow(
|
||||
NextcloudShare share, {
|
||||
bool removable = true,
|
||||
bool copyUrl = false,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final label = switch (share.shareType) {
|
||||
ShareType.publicLink => 'Public link',
|
||||
_ => share.sharedWithDisplayName ?? 'Shared',
|
||||
};
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
_shareTypeIcon(share.shareType),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
title: Text(label),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (copyUrl && share.url != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
tooltip: 'Copy link',
|
||||
onPressed: () => _copy(share.url!, 'link'),
|
||||
),
|
||||
if (removable)
|
||||
IconButton(
|
||||
icon: Icon(Icons.close_rounded, color: colorScheme.error),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => _removeShare(share),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_requested) {
|
||||
_requested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final provider = context.watch<ServerProvider>();
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final userGroupShares = _shares
|
||||
.where(
|
||||
(s) =>
|
||||
s.shareType == ShareType.user || s.shareType == ShareType.group,
|
||||
)
|
||||
.toList();
|
||||
final publicLinkShares = _shares
|
||||
.where((s) => s.shareType == ShareType.publicLink)
|
||||
.toList();
|
||||
final emailShares = _shares
|
||||
.where((s) => s.shareType == ShareType.email)
|
||||
.toList();
|
||||
final internalLink = internalLinkFor(provider.serverUrl, widget.item.id);
|
||||
|
||||
return ListView(
|
||||
controller: widget.scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
children: [
|
||||
Text(
|
||||
widget.item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_sectionHeader('Internal shares'),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type names or teams',
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: _isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(14),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerLow,
|
||||
),
|
||||
onChanged: _search,
|
||||
),
|
||||
for (final sharee in _searchResults)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
sharee.type == ShareeType.group
|
||||
? Icons.groups_rounded
|
||||
: Icons.person_rounded,
|
||||
),
|
||||
title: Text(sharee.label),
|
||||
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
|
||||
onTap: () => _addSharee(sharee),
|
||||
),
|
||||
for (final share in userGroupShares) _shareRow(share),
|
||||
if (_inherited.isNotEmpty)
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.more_horiz_rounded),
|
||||
title: Text('Others with access (${_inherited.length})'),
|
||||
children: [
|
||||
for (final share in _inherited)
|
||||
_shareRow(share, removable: false),
|
||||
],
|
||||
),
|
||||
if (internalLink != null)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.link_rounded),
|
||||
title: const Text('Internal link'),
|
||||
subtitle: const Text('For people who already have access'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
tooltip: 'Copy link',
|
||||
onPressed: () => _copy(internalLink, 'internal link'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
_sectionHeader('External shares'),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type an email',
|
||||
prefixIcon: const Icon(Icons.email_outlined),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerLow,
|
||||
),
|
||||
onSubmitted: (_) => _addEmailShare(),
|
||||
),
|
||||
for (final share in emailShares) _shareRow(share),
|
||||
const SizedBox(height: 8),
|
||||
for (final share in publicLinkShares) _shareRow(share, copyUrl: true),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.link_rounded),
|
||||
title: const Text('Create public link'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
tooltip: 'Create public link',
|
||||
onPressed: _createPublicLink,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
|
||||
String sortFieldLabel(FileSortField field) {
|
||||
switch (field) {
|
||||
case FileSortField.name:
|
||||
return 'Name';
|
||||
case FileSortField.dateCreated:
|
||||
return 'Date created';
|
||||
case FileSortField.dateModified:
|
||||
return 'Date modified';
|
||||
case FileSortField.size:
|
||||
return 'Size';
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropdown trigger for choosing which field a list/grid is sorted by,
|
||||
/// shared by the Files and Photos tabs.
|
||||
class SortMenuButton extends StatelessWidget {
|
||||
final FileSortField field;
|
||||
final ValueChanged<FileSortField> onChanged;
|
||||
|
||||
const SortMenuButton({
|
||||
super.key,
|
||||
required this.field,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return PopupMenuButton<FileSortField>(
|
||||
initialValue: field,
|
||||
onSelected: onChanged,
|
||||
offset: const Offset(0, 36),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
itemBuilder: (context) => FileSortField.values.map((f) {
|
||||
return PopupMenuItem(
|
||||
value: f,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
child: f == field
|
||||
? Icon(
|
||||
Icons.check_rounded,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(sortFieldLabel(f)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
// A plain Text child in a Row always takes its full intrinsic
|
||||
// width no matter what maxLines/overflow say - Flexible is what
|
||||
// actually lets it shrink to whatever space this row has left
|
||||
// (it's squeezed by favorite/hidden/storage/list-grid toggles
|
||||
// alongside it) so the ellipsis can kick in instead of
|
||||
// overflowing.
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
sortFieldLabel(field),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_drop_down_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Wraps [child] as a sliver that pins to the top of the scroll view when
|
||||
/// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away
|
||||
/// normally with `pinned: false`. [height] must match the child's actual
|
||||
/// rendered height - Flutter's sliver protocol needs an extent up front,
|
||||
/// not one measured after layout, so callers own getting this right for
|
||||
/// whatever fixed-height content they pass in.
|
||||
class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
final double height;
|
||||
final Widget child;
|
||||
|
||||
const StickyHeaderDelegate({required this.height, required this.child});
|
||||
|
||||
@override
|
||||
double get minExtent => height;
|
||||
|
||||
@override
|
||||
double get maxExtent => height;
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context,
|
||||
double shrinkOffset,
|
||||
bool overlapsContent,
|
||||
) {
|
||||
return Material(color: Theme.of(context).colorScheme.surface, child: child);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
|
||||
return oldDelegate.height != height || oldDelegate.child != child;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
|
||||
/// Wraps [child] with the user's configured swipe-left/swipe-right actions
|
||||
/// (favorite, delete, share, or none) — shared between the Files list view
|
||||
/// and the Photos grid so both behave identically. Callers own the actual
|
||||
/// side effects (favoriting, sharing, deleting); this widget only decides
|
||||
/// which action a given swipe direction maps to and drives the gesture.
|
||||
class SwipeableItem extends StatelessWidget {
|
||||
final Key itemKey;
|
||||
final ServerProvider provider;
|
||||
final Future<void> Function() onFavorite;
|
||||
final Future<void> Function() onShare;
|
||||
final Future<void> Function() onDelete;
|
||||
final Widget child;
|
||||
|
||||
const SwipeableItem({
|
||||
super.key,
|
||||
required this.itemKey,
|
||||
required this.provider,
|
||||
required this.onFavorite,
|
||||
required this.onShare,
|
||||
required this.onDelete,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
static DismissDirection _directionFor(SwipeAction left, SwipeAction right) {
|
||||
final hasLeft = left != SwipeAction.none;
|
||||
final hasRight = right != SwipeAction.none;
|
||||
if (hasLeft && hasRight) return DismissDirection.horizontal;
|
||||
if (hasRight) return DismissDirection.startToEnd;
|
||||
if (hasLeft) return DismissDirection.endToStart;
|
||||
return DismissDirection.none;
|
||||
}
|
||||
|
||||
static Widget _background(
|
||||
BuildContext context,
|
||||
SwipeAction action,
|
||||
Alignment alignment,
|
||||
) {
|
||||
if (action == SwipeAction.none) return const SizedBox.shrink();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
switch (action) {
|
||||
case SwipeAction.delete:
|
||||
color = colorScheme.error;
|
||||
icon = Icons.delete_rounded;
|
||||
case SwipeAction.favorite:
|
||||
color = Colors.red.shade400;
|
||||
icon = Icons.favorite_rounded;
|
||||
case SwipeAction.share:
|
||||
color = colorScheme.primary;
|
||||
icon = Icons.share_rounded;
|
||||
case SwipeAction.none:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: color,
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Icon(icon, color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rightAction = provider.swipeRightAction;
|
||||
final leftAction = provider.swipeLeftAction;
|
||||
final direction = _directionFor(leftAction, rightAction);
|
||||
if (direction == DismissDirection.none) return child;
|
||||
|
||||
return Dismissible(
|
||||
key: itemKey,
|
||||
direction: direction,
|
||||
background: _background(context, rightAction, Alignment.centerLeft),
|
||||
secondaryBackground: _background(
|
||||
context,
|
||||
leftAction,
|
||||
Alignment.centerRight,
|
||||
),
|
||||
confirmDismiss: (dir) async {
|
||||
final action = dir == DismissDirection.startToEnd
|
||||
? rightAction
|
||||
: leftAction;
|
||||
switch (action) {
|
||||
case SwipeAction.favorite:
|
||||
HapticFeedback.selectionClick();
|
||||
await onFavorite();
|
||||
return false;
|
||||
case SwipeAction.share:
|
||||
HapticFeedback.selectionClick();
|
||||
await onShare();
|
||||
return false;
|
||||
case SwipeAction.delete:
|
||||
HapticFeedback.mediumImpact();
|
||||
return true; // Actual delete happens in onDismissed.
|
||||
case SwipeAction.none:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onDismissed: (dir) {
|
||||
final action = dir == DismissDirection.startToEnd
|
||||
? rightAction
|
||||
: leftAction;
|
||||
if (action == SwipeAction.delete) onDelete();
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
|
||||
String formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
/// "5.16 GB / 100 GB" — or "5.16 GB / ∞" when the server reports no quota
|
||||
/// limit (or doesn't report one at all).
|
||||
String formatQuota(NextcloudUserQuota quota) {
|
||||
final total = quota.totalBytes > 0 ? formatBytes(quota.totalBytes) : '∞';
|
||||
return '${formatBytes(quota.usedBytes)} / $total';
|
||||
}
|
||||
|
||||
/// Shared "pull down to see sync status" scroll header used by both the
|
||||
/// Files and Photos tabs: a compact sync-status chip that expands into a
|
||||
/// storage-quota panel (Google Photos' "pull to see backup status"
|
||||
/// pattern), sitting on a gray backdrop above a white rounded-top card that
|
||||
/// holds the tab's own content slivers.
|
||||
class SyncedHeaderScaffold extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
final ServerProvider provider;
|
||||
|
||||
/// Trailing icons in the app bar (e.g. an upload button, the avatar).
|
||||
final List<Widget> actions;
|
||||
|
||||
/// The tab's own content, already built as slivers (a filter/sort row,
|
||||
/// breadcrumbs, the grid/list, empty/loading/error states, etc.). Wrapped
|
||||
/// internally in the white rounded-top card.
|
||||
final List<Widget> contentSlivers;
|
||||
|
||||
/// What a deliberate pull-to-sync should trigger. Defaults to
|
||||
/// [ServerProvider.refreshData] (the current folder's listing); pass e.g.
|
||||
/// `provider.fetchTrash` for a tab backed by different data.
|
||||
final Future<void> Function()? onRefresh;
|
||||
|
||||
const SyncedHeaderScaffold({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.provider,
|
||||
required this.actions,
|
||||
required this.contentSlivers,
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SyncedHeaderScaffold> createState() => _SyncedHeaderScaffoldState();
|
||||
}
|
||||
|
||||
class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
|
||||
static const double _lockThreshold = 100;
|
||||
static const double _syncThreshold = 14;
|
||||
static const double _pullingEpsilon = 8;
|
||||
|
||||
bool _headerLocked = false;
|
||||
bool _isPulling = false;
|
||||
double _pullDistance = 0;
|
||||
|
||||
void _revealPanel() {
|
||||
if (widget.scrollController.hasClients) {
|
||||
widget.scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _lockOpen() {
|
||||
if (!_headerLocked) setState(() => _headerLocked = true);
|
||||
_revealPanel();
|
||||
}
|
||||
|
||||
bool _handleScrollNotification(ScrollNotification notification) {
|
||||
final metrics = notification.metrics;
|
||||
if (metrics.pixels < metrics.minScrollExtent) {
|
||||
_pullDistance = metrics.minScrollExtent - metrics.pixels;
|
||||
final pulling = _pullDistance > _pullingEpsilon;
|
||||
if (pulling != _isPulling && !_headerLocked) {
|
||||
setState(() => _isPulling = pulling);
|
||||
}
|
||||
} else if (_isPulling) {
|
||||
setState(() => _isPulling = false);
|
||||
}
|
||||
|
||||
if (notification is ScrollEndNotification) {
|
||||
final pulled = _pullDistance;
|
||||
_pullDistance = 0;
|
||||
if (_isPulling) setState(() => _isPulling = false);
|
||||
|
||||
final refresh = widget.onRefresh ?? widget.provider.refreshData;
|
||||
if (pulled >= _lockThreshold) {
|
||||
_lockOpen();
|
||||
refresh();
|
||||
} else if (pulled >= _syncThreshold) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final provider = widget.provider;
|
||||
|
||||
final Widget leadingWidget;
|
||||
if (_headerLocked) {
|
||||
leadingWidget = IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_up_rounded),
|
||||
tooltip: 'Collapse',
|
||||
onPressed: () => setState(() => _headerLocked = false),
|
||||
);
|
||||
} else if (_isPulling) {
|
||||
leadingWidget = const SizedBox.shrink();
|
||||
} else {
|
||||
leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen);
|
||||
}
|
||||
|
||||
return ColoredBox(
|
||||
color: colorScheme.surfaceContainer,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
// Plain BouncingScrollPhysics only bounces/overscrolls reliably
|
||||
// once content already fills the viewport — with too little
|
||||
// content (e.g. a single item) the pull gesture can fail to
|
||||
// register at all. AlwaysScrollableScrollPhysics keeps the pull
|
||||
// (and therefore the sync header) working regardless of content
|
||||
// length.
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
expandedHeight: _headerLocked ? 190 : kToolbarHeight,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
surfaceTintColor: colorScheme.surfaceContainer,
|
||||
scrolledUnderElevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
leadingWidth: _headerLocked ? 56 : 160,
|
||||
leading: leadingWidget,
|
||||
actions: widget.actions,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: _SyncedStretchPanel(
|
||||
provider: provider,
|
||||
forceVisible: _headerLocked,
|
||||
),
|
||||
),
|
||||
),
|
||||
DecoratedSliver(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
sliver: SliverMainAxisGroup(slivers: widget.contentSlivers),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact, always-visible summary of the same status shown by
|
||||
/// [_SyncedStretchPanel] — tapping it is a discoverable shortcut for pulling
|
||||
/// the header down to expand that same "Synced" view.
|
||||
class _SyncStatusChip extends StatelessWidget {
|
||||
final ServerProvider provider;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SyncStatusChip({required this.provider, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final quota = provider.quota;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const StadiumBorder(),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const StadiumBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
provider.isLoading
|
||||
? Icons.cloud_sync_rounded
|
||||
: Icons.cloud_done_outlined,
|
||||
color: colorScheme.primary,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
quota != null ? formatQuota(quota) : 'Sync',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Revealed by pulling the header down past its normal height (Google
|
||||
/// Photos' "pull to see backup status" pattern). Shows storage quota;
|
||||
/// [_SyncedHeaderScaffoldState] decides via [forceVisible] whether it stays
|
||||
/// locked open.
|
||||
class _SyncedStretchPanel extends StatelessWidget {
|
||||
final ServerProvider provider;
|
||||
|
||||
/// Keeps the panel fully shown even without live overscroll — used once
|
||||
/// the header has "locked" open after a deliberate pull-down.
|
||||
final bool forceVisible;
|
||||
|
||||
const _SyncedStretchPanel({
|
||||
required this.provider,
|
||||
this.forceVisible = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final topInset = MediaQuery.of(context).padding.top;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final settings = context
|
||||
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final maxExtent = settings?.maxExtent ?? constraints.maxHeight;
|
||||
final stretch = (constraints.maxHeight - maxExtent).clamp(0.0, 80.0);
|
||||
// Reaches 1.0 (title fully hidden) at 50px of pull — comfortably
|
||||
// before the 100px lock threshold.
|
||||
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
|
||||
final quota = provider.quota;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (progress > 0)
|
||||
// Anchored below the toolbar row (icons), never the bottom,
|
||||
// so it can never share space with the title above.
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: topInset + kToolbarHeight + 4,
|
||||
child: Opacity(
|
||||
opacity: progress,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
provider.isLoading ? 'Syncing…' : 'Synced',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (provider.serverUrl.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
provider.serverUrl.replaceFirst(
|
||||
RegExp(r'^https?://'),
|
||||
'',
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_done_outlined,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
quota != null
|
||||
? formatQuota(quota)
|
||||
: 'Pull to refresh',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user