Rebuild UI on the Noo design system
Replaces the Material 3 screens with the Noo design system: warm neutrals, one violet accent, pill controls, Schibsted Grotesk/ Instrument Sans, Lucide icons, no gradients/shadows. - New lib/widgets/noo/ component kit (core, lists, files, media, nav, overlays) plus lib/theme/design_tokens.dart for the color/type/space/ radius/motion tokens. - Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and toolbar), every tab (Files/Offline, Photos, Favorites, Recent, Activity, Trash, Shares), Settings, the lock and login screens, the media viewer, search, the details/share sheets, and the share-upload and move/copy destination pickers. - Added a design canvas (linked from DESIGN_SYSTEM.md) covering the screens the spec didn't already describe, with matching Android and iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4. - Removed now-dead legacy widgets (media_grid_tile, swipeable_item, sync_status_badge, selectable_thumbnail) and updated architecture.md/styling.md/standards.md to describe the new structure and component/testing conventions. - Added widget tests for the noo/ component kit. This is a UI-only rework: no provider/model/service behavior changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../views/account_view.dart';
|
||||
import '../../views/login_view.dart';
|
||||
import '../../views/search_view.dart';
|
||||
import '../noo/core/noo_avatar.dart';
|
||||
import '../noo/core/noo_search_field.dart';
|
||||
import '../noo/lists/noo_grouped_list.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../noo/overlays/noo_dialog.dart';
|
||||
import '../noo/overlays/noo_sheet.dart';
|
||||
|
||||
// Small pieces shared by the mobile drawer/top bar and the desktop
|
||||
// sidebar/toolbar, so both shells show the same account/storage data and
|
||||
// open the same screens.
|
||||
|
||||
String accountInitial(String username) =>
|
||||
username.isNotEmpty ? username[0].toUpperCase() : '?';
|
||||
|
||||
String serverHost(String serverUrl) =>
|
||||
serverUrl.replaceFirst(RegExp(r'^https?://'), '');
|
||||
|
||||
void openSettings(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AccountView()),
|
||||
);
|
||||
}
|
||||
|
||||
void openSearch(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SearchView()),
|
||||
);
|
||||
}
|
||||
|
||||
// A local copy rather than importing `synced_header_scaffold.dart`'s, since
|
||||
// that legacy header is on its way out with the rework.
|
||||
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';
|
||||
}
|
||||
|
||||
/// 0-1 share of the quota used (0 when unknown or unlimited).
|
||||
double quotaFraction(NextcloudUserQuota? quota) =>
|
||||
quota != null && quota.totalBytes > 0
|
||||
? (quota.usedBytes / quota.totalBytes).clamp(0.0, 1.0)
|
||||
: 0;
|
||||
|
||||
/// "38.2 GB of 100 GB used" (drawer).
|
||||
String quotaUsedLabel(NextcloudUserQuota? quota) {
|
||||
if (quota == null) return 'Storage usage unavailable';
|
||||
final total = quota.totalBytes > 0 ? _formatBytes(quota.totalBytes) : '∞';
|
||||
return '${_formatBytes(quota.usedBytes)} of $total used';
|
||||
}
|
||||
|
||||
/// "38.2 GB of 100 GB" (sidebar, next to the "Storage" label).
|
||||
String quotaDetail(NextcloudUserQuota? quota) {
|
||||
if (quota == null) return 'Unavailable';
|
||||
final total = quota.totalBytes > 0 ? _formatBytes(quota.totalBytes) : '∞';
|
||||
return '${_formatBytes(quota.usedBytes)} of $total';
|
||||
}
|
||||
|
||||
/// The saved-accounts switcher behind the drawer's chevron and the desktop
|
||||
/// sidebar's account card: every saved account (tap to switch, same
|
||||
/// `switchAccount` call - and login-lock gate - as Settings' account list),
|
||||
/// "Add account", and a shortcut to the full account settings. A sheet on
|
||||
/// mobile, a dialog on desktop.
|
||||
void showAccountSwitcher(BuildContext context) {
|
||||
final session = context.read<SessionController>();
|
||||
|
||||
List<Widget> content(BuildContext overlayContext) {
|
||||
void close() => Navigator.pop(overlayContext);
|
||||
return [
|
||||
NooGroupedList(
|
||||
label: 'Accounts',
|
||||
children: [
|
||||
for (final account in session.accounts)
|
||||
_AccountSwitchRow(
|
||||
name: account.username,
|
||||
host: serverHost(account.serverUrl),
|
||||
active: account.id == session.activeAccountId,
|
||||
onTap: account.id == session.activeAccountId
|
||||
? null
|
||||
: () {
|
||||
close();
|
||||
session.switchAccount(account.id);
|
||||
},
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.plus,
|
||||
accent: true,
|
||||
label: const Text('Add account'),
|
||||
onTap: () {
|
||||
close();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LoginView(isAddingAccount: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.settings,
|
||||
label: const Text('Manage accounts'),
|
||||
onTap: () {
|
||||
close();
|
||||
openSettings(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
showNooDialog(
|
||||
context,
|
||||
title: 'Switch account',
|
||||
children: [Builder(builder: (c) => Column(children: content(c)))],
|
||||
);
|
||||
} else {
|
||||
showNooSheet(
|
||||
context,
|
||||
children: [Builder(builder: (c) => Column(children: content(c)))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A switcher row: avatar, name over host, and a check on the active one.
|
||||
/// `NooSettingsRow` only takes an icon, not an avatar, hence its own row.
|
||||
class _AccountSwitchRow extends StatelessWidget {
|
||||
final String name;
|
||||
final String host;
|
||||
final bool active;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _AccountSwitchRow({
|
||||
required this.name,
|
||||
required this.host,
|
||||
required this.active,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
NooAvatar(
|
||||
initials: accountInitial(name),
|
||||
current: active,
|
||||
size: 36,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.body.copyWith(
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
host,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(
|
||||
height: 1.2,
|
||||
color: colors.fg3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (active)
|
||||
Icon(LucideIcons.check, size: 20, color: colors.accentText),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current user's avatar in the mobile top bar. Tap opens Settings;
|
||||
/// swiping up/down on it cycles to the next/previous saved account
|
||||
/// immediately, with no confirmation - a shortcut alongside the full
|
||||
/// switcher (drawer chevron, Settings' account list).
|
||||
class ShellAvatarButton extends StatelessWidget {
|
||||
final double hitBox;
|
||||
|
||||
const ShellAvatarButton({super.key, this.hitBox = 48});
|
||||
|
||||
Future<void> _handleVerticalSwipe(
|
||||
BuildContext context,
|
||||
DragEndDetails details,
|
||||
) async {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
if (velocity.abs() < 250) return;
|
||||
|
||||
final session = context.read<SessionController>();
|
||||
final target = velocity < 0
|
||||
? await session.cycleToNextAccount()
|
||||
: await session.cycleToPreviousAccount();
|
||||
if (target == null || !context.mounted) return;
|
||||
|
||||
HapticFeedback.selectionClick();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Switched to ${target.username}'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
return Tooltip(
|
||||
message: 'Settings',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Settings',
|
||||
child: GestureDetector(
|
||||
onVerticalDragEnd: (details) =>
|
||||
_handleVerticalSwipe(context, details),
|
||||
child: SizedBox.square(
|
||||
dimension: hitBox,
|
||||
child: InkResponse(
|
||||
onTap: () => openSettings(context),
|
||||
radius: hitBox / 2,
|
||||
child: Center(
|
||||
child: NooAvatar(
|
||||
initials: accountInitial(session.username),
|
||||
current: true,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A [NooSearchField] that doesn't take input itself but opens
|
||||
/// `SearchView` on tap - the shell's search entry point (iOS top bar,
|
||||
/// desktop toolbar). Search has its own screen with results, so the field
|
||||
/// here is only a launcher.
|
||||
class ShellSearchLauncher extends StatelessWidget {
|
||||
final bool ios;
|
||||
final bool onSurface;
|
||||
|
||||
const ShellSearchLauncher({
|
||||
super.key,
|
||||
this.ios = false,
|
||||
this.onSurface = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Search',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => openSearch(context),
|
||||
child: AbsorbPointer(
|
||||
child: ExcludeFocus(
|
||||
child: NooSearchField(
|
||||
placeholder: 'Search files',
|
||||
ios: ios,
|
||||
onSurface: onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user