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:
+236
-89
@@ -1,14 +1,29 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/app_tab.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../providers/files_controller.dart';
|
||||
import '../providers/settings_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import '../widgets/noo/files/noo_file_kind.dart';
|
||||
import '../widgets/noo/files/noo_file_row.dart';
|
||||
import '../widgets/noo/files/noo_file_table.dart';
|
||||
import '../widgets/noo/core/noo_search_field.dart';
|
||||
import '../widgets/noo/nav/noo_top_bar.dart';
|
||||
import '../widgets/noo/nav/noo_toolbar.dart';
|
||||
import '../widgets/noo/noo_layout.dart';
|
||||
import 'file_viewer_screen.dart';
|
||||
|
||||
/// Full-text file search, pushed from the shell's search entry point
|
||||
/// (`openSearch` in `widgets/shell/shell_common.dart`). Debounces input,
|
||||
/// then lists matches as file rows - tapping one jumps to the result on the
|
||||
/// Files tab (see [_openResult]). Per DESIGN_SYSTEM.md §4 "Search".
|
||||
class SearchView extends StatefulWidget {
|
||||
const SearchView({super.key});
|
||||
|
||||
@@ -74,25 +89,6 @@ class _SearchViewState extends State<SearchView> {
|
||||
}
|
||||
}
|
||||
|
||||
IconData _iconFor(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigates the Files tab to [item] (its own folder if it's a folder,
|
||||
/// its parent folder otherwise - rebuilding the whole breadcrumb trail
|
||||
/// from root rather than assuming it's under wherever the user was
|
||||
@@ -113,98 +109,249 @@ class _SearchViewState extends State<SearchView> {
|
||||
}
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_controller.clear();
|
||||
_focusNode.requestFocus();
|
||||
_onChanged('');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final colors = context.nooColors;
|
||||
final desktop = NooLayout.isDesktop(context);
|
||||
|
||||
return Scaffold(
|
||||
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...',
|
||||
elevation: const WidgetStatePropertyAll(0),
|
||||
onChanged: _onChanged,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
backgroundColor: colors.bg,
|
||||
appBar: desktop
|
||||
? NooToolbar(
|
||||
titleWidget: _buildField(onSurface: true),
|
||||
actions: [
|
||||
IconTheme.merge(
|
||||
data: IconThemeData(color: colors.fg1, size: 24),
|
||||
child: NooTopBarButton(
|
||||
icon: LucideIcons.x,
|
||||
tooltip: 'Close',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
trailing: [
|
||||
if (_controller.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
_onChanged('');
|
||||
},
|
||||
],
|
||||
)
|
||||
: null,
|
||||
body: SafeArea(
|
||||
top: !desktop,
|
||||
child: desktop
|
||||
? _buildBody(context, desktop)
|
||||
: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.xs,
|
||||
NooSpace.xs,
|
||||
NooSpace.md,
|
||||
NooSpace.xs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconTheme.merge(
|
||||
data: IconThemeData(color: colors.fg1, size: 24),
|
||||
child: NooTopBarButton(
|
||||
icon: LucideIcons.arrowLeft,
|
||||
tooltip: 'Back',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildField(onSurface: false)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildBody(context, desktop)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField({required bool onSurface}) {
|
||||
// The pill `NooSearchField`, not the inset-panel `NooTextField` - this
|
||||
// is a search field (DESIGN_SYSTEM.md's "Search field" component,
|
||||
// radius 999 outside the one iOS-inline exception), not a form input.
|
||||
// `ios: false` matches the Android shape we're building against now;
|
||||
// revisit once the iOS large-title/inline-field entry point is built.
|
||||
return NooSearchField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
placeholder: 'Search files',
|
||||
onChanged: _onChanged,
|
||||
onSurface: onSurface,
|
||||
ios: false,
|
||||
trailing: _controller.text.isEmpty
|
||||
? null
|
||||
: _ClearFieldButton(onTap: _clear),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, bool desktop) {
|
||||
final colors = context.nooColors;
|
||||
|
||||
if (_isSearching) {
|
||||
return Center(child: CircularProgressIndicator(color: colors.accent));
|
||||
}
|
||||
if (_error != null) {
|
||||
return _buildMessage(
|
||||
context,
|
||||
icon: LucideIcons.circleAlert,
|
||||
iconColor: colors.danger,
|
||||
title: 'Couldn\'t search',
|
||||
message: _error,
|
||||
);
|
||||
}
|
||||
if (!_hasSearched) {
|
||||
return _buildMessage(
|
||||
context,
|
||||
icon: LucideIcons.search,
|
||||
iconColor: colors.fg3,
|
||||
title: 'Search your files',
|
||||
message: 'Find files and folders by name.',
|
||||
);
|
||||
}
|
||||
if (_results.isEmpty) {
|
||||
return _buildMessage(
|
||||
context,
|
||||
icon: LucideIcons.searchX,
|
||||
iconColor: colors.fg3,
|
||||
title: 'No results found',
|
||||
);
|
||||
}
|
||||
return desktop ? _buildDesktopResults(context) : _buildMobileResults(context);
|
||||
}
|
||||
|
||||
Widget _buildMessage(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required String title,
|
||||
String? message,
|
||||
}) {
|
||||
final colors = context.nooColors;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(NooSpace.xl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 56, color: iconColor),
|
||||
const SizedBox(height: NooSpace.sm),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: NooText.cardTitle.copyWith(color: colors.fg2),
|
||||
),
|
||||
Expanded(child: _buildBody(context, colorScheme, theme)),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: NooSpace.xxs),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: NooText.body.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
ThemeData theme,
|
||||
) {
|
||||
if (_isSearching) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!_hasSearched) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (_results.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No results found',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _buildMobileResults(BuildContext context) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
NooSpace.sm,
|
||||
NooSpace.xl,
|
||||
),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: _results.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _results[index];
|
||||
return ListTile(
|
||||
leading: Icon(_iconFor(item.type), color: colorScheme.primary),
|
||||
title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
item.isFolder
|
||||
? item.path
|
||||
: DateFormat.yMMMd().format(item.lastModified),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
child: NooFileRow(
|
||||
kind: NooFileKind.from(
|
||||
name: item.name,
|
||||
mimeType: item.mimeType,
|
||||
isDirectory: item.isFolder,
|
||||
),
|
||||
name: item.name,
|
||||
meta: _mobileMeta(item),
|
||||
favorite: item.isFavorite,
|
||||
iosStyle: NooLayout.iosStyle(context),
|
||||
onTap: () => _openResult(item),
|
||||
),
|
||||
),
|
||||
onTap: () => _openResult(item),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopResults(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.xl,
|
||||
vertical: NooSpace.sm,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
|
||||
for (final item in _results)
|
||||
NooFileTableRow(
|
||||
kind: NooFileKind.from(
|
||||
name: item.name,
|
||||
mimeType: item.mimeType,
|
||||
isDirectory: item.isFolder,
|
||||
),
|
||||
name: item.name,
|
||||
col2: DateFormat.yMMMd().format(item.lastModified),
|
||||
col3: item.isFolder ? item.path : p.dirname(item.path),
|
||||
favorite: item.isFavorite,
|
||||
onTap: () => _openResult(item),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors the pre-rework screen's exact copy: a folder result shows its
|
||||
/// own full path (there's no separate "location" field to fall back to),
|
||||
/// a file result shows when it was last modified.
|
||||
String _mobileMeta(NextcloudItem item) =>
|
||||
item.isFolder ? item.path : DateFormat.yMMMd().format(item.lastModified);
|
||||
}
|
||||
|
||||
/// Small round "clear" button for [NooTextField.trailing] - clears the
|
||||
/// query and puts focus back in the field, same as the old `SearchBar`'s
|
||||
/// trailing close icon.
|
||||
class _ClearFieldButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ClearFieldButton({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return SizedBox.square(
|
||||
dimension: 32,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Icon(LucideIcons.x, size: 16, color: colors.fg3),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user