Files
noo/lib/views/search_view.dart
T
ayushyaandClaude Opus 5.5 56784ef212 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>
2026-09-26 01:51:58 -04:00

358 lines
11 KiB
Dart

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});
@override
State<SearchView> createState() => _SearchViewState();
}
class _SearchViewState extends State<SearchView> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
Timer? _debounce;
List<NextcloudItem> _results = [];
bool _isSearching = false;
String? _error;
bool _hasSearched = false;
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
void _onChanged(String query) {
_debounce?.cancel();
if (query.trim().isEmpty) {
setState(() {
_results = [];
_hasSearched = false;
_error = null;
});
return;
}
_debounce = Timer(
const Duration(milliseconds: 500),
() => _runSearch(query),
);
}
Future<void> _runSearch(String query) async {
final files = context.read<FilesController>();
setState(() {
_isSearching = true;
_error = null;
});
try {
final results = await files.searchFiles(query);
if (!mounted) return;
setState(() {
_results = results;
_hasSearched = true;
_isSearching = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString().replaceAll('Exception: ', '');
_isSearching = false;
_hasSearched = true;
});
}
}
/// 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
/// browsing before), switches the shell to the Files tab, and closes
/// search. For a file, also opens it once back on Files.
Future<void> _openResult(NextcloudItem item) async {
final files = context.read<FilesController>();
final settings = context.read<SettingsController>();
final navigator = Navigator.of(context);
final folderPath = item.isFolder ? item.path : p.dirname(item.path);
await files.navigateToAbsoluteFolder(folderPath);
settings.requestTab(AppTab.files);
navigator.popUntil((route) => route.isFirst);
if (!item.isFolder) {
navigator.push(FileViewerScreen.route(item: item));
}
}
void _clear() {
_controller.clear();
_focusNode.requestFocus();
_onChanged('');
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
return Scaffold(
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),
),
),
],
)
: 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),
),
if (message != null) ...[
const SizedBox(height: NooSpace.xxs),
Text(
message,
textAlign: TextAlign.center,
style: NooText.body.copyWith(color: colors.fg3),
),
],
],
),
),
);
}
Widget _buildMobileResults(BuildContext context) {
return ListView.builder(
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 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),
),
),
);
},
);
}
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),
),
),
);
}
}