Author SHA1 Message Date
Ayushya Amitabh 0a4608e6c3 Release 1.0.3: refine tablet layouts, pointer selection and account sync cleanup
Build iOS / build (push) Successful in 4m45s
Build App Bundle / build (push) Successful in 7m7s
2026-10-09 00:36:42 -04:00
67 changed files with 1740 additions and 451 deletions
+9
View File
@@ -1064,3 +1064,12 @@ backup) with state in `<...>/sync-state/<accountId>/*.json`.
macOS) swallows any directory named `Sync` - hence `SyncEngine/`.
Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes.
Account removal awaits native `removeAccountData` before deleting credentials or
account preferences. Native cleanup stops account sync work, takes the sync mutex,
and deletes both its local mirror and durable sync records. Signing out continues
to retain local data. Cleanup errors keep the saved account available for retry.
Account display names from the OCS cloud/user response are cached in SavedAccount.displayName and used for account labels/avatars (username fallback). Authentication, IDs, DAV paths and native sync continue using the username. Cached names survive offline startup and inactive accounts; stale responses cannot update another session.
Settings → Accounts lists the login username explicitly for identifying saved credentials; other account menus continue using the display name.
+43 -4
View File
@@ -436,11 +436,50 @@ Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flut
- The wide tablet branch in `main.dart` is wrapped in `SafeArea` so tablets keep
system bars clear of the sidebar; the sidebar has no divider between pinned
and hidden tabs.
- Wide tablet Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
pushed category screens render the wide tablet card layout.
- Tablet Settings keeps categories in a sidebar with a compact account card;
the selected category renders inline in the right pane. Phone categories
continue to push a separate screen.
- Settings that don't apply in wide-tablet (Appearance nav options,
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
Photos) take their column count from `NooLayout.gridColumns` (min tile
width, never below the phone count), so a tablet in portrait gets more
columns rather than a few huge tiles.
width, never below the phone count), so wider phone-layout windows get
more columns rather than a few huge tiles.
Tablet sidebar layout uses a 600dp minimum shortest window side, regardless of
orientation. Portrait iPads and Android tablets therefore keep the sidebar;
landscape phones and narrow split-screen windows retain phone navigation.
About is the last Settings category. `SettingsAboutSection` reads installed
version, build number and application identifier through `package_info_plus`,
so release build overrides appear correctly without hardcoded version text.
The tablet Search toolbar is wrapped in `SafeArea` when used as a route's
app bar, keeping its field and Close action below the system status bar.
File tile grids share `NooLayout.fileGridDelegate`: columns derive from the
actual content-pane width (220dp maximum tile width), and row heights reserve
the thumbnail plus scaled name/meta text. Tablet file tables collapse the date
column below 600dp and the second metadata column below 420dp of available pane
width, sharing widths between header and
rows. PDF viewer top-bar space stays reserved when controls hide. About links
open the website, GitHub repository and privacy policy in the external browser.
Selection bars use the actual content-pane width: compact panes show only
the icons that fit, with the rest in More; wide panes measure labels before
choosing the inline actions. The selection count truncates rather than overflow.
Tablet shell sidebars group tab rows in a surface card, put Search below the
account card, and pin quota/Settings at the bottom. `TabletAccountMenu` expands
inline with AnimatedSize and a rotating chevron for account switching and
Add/Manage controls. Tablet Settings uses a category sidebar with a compact
account card, showing the selected section in a rounded content pane with a
short fade/slide transition; phones retain pushed category screens. Both
transitions respect reduced-motion settings. Sidebar widths adapt from 200
to 256dp as window width grows.
Tablet content panes use the theme surface2 tone, including pinned filter headers, to preserve rounded top corners and contrast with surface-colored tiles. Panes fill their allocated width; photo grids derive tile counts from their actual sliver width. Settings category headings appear only in the toolbar, while distinct subsection headings remain.
Tablet Settings hides bottom-navigation/menu/avatar-position/search-placement controls, upload-button style, swipe-action category, and bottom-tab tap-to-scroll preference. Frosted glass remains available for media viewer panels. Phone preferences stay saved when their controls are hidden.
Pointer selection: NooPointerSelection tracks Flutter mouse presence and trackpad events across platforms. Selectable file rows, tables, tiles and photos expose separate checkboxes when pointer input is available. Checkbox taps toggle selection without opening content. Offline and single-file picker restrictions are preserved; touch-only long press behavior is unchanged. Trackpads without mouse presence are detected on first use, as Flutter has no universal attached-trackpad enumeration.
+3
View File
@@ -56,3 +56,6 @@ app.*.map.json
key.properties
.env
.env.*
# Kotlin compiler session cache
/android/.kotlin/
@@ -57,6 +57,7 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
)
val request = OneTimeWorkRequestBuilder<ConflictResolveWorker>()
.setInputData(data)
.addTag("noo_sync_account_$accountId")
.build()
WorkManager.getInstance(context).enqueue(request)
}
@@ -1,5 +1,7 @@
package dev.ayushya.noo
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withLock
import android.Manifest
import android.content.ClipData
import android.content.Intent
@@ -140,6 +142,7 @@ class MainActivity : FlutterFragmentActivity() {
syncStatusMap(SyncStatusBus.snapshot(), call.argument<String>("accountId")),
)
"resolveConflict" -> resolveConflict(call, result)
"removeAccountData" -> removeAccountData(call, result)
"removeLocalSync" -> removeLocalSync(call, result)
else -> result.notImplemented()
}
@@ -478,6 +481,32 @@ class MainActivity : FlutterFragmentActivity() {
/// walks/deletes a directory tree; `result.success` is posted back via
/// [mainHandler] since MethodChannel results must be delivered on the
/// platform thread.
private fun removeAccountData(call: MethodCall, result: MethodChannel.Result) {
val accountId = call.argument<String>("accountId")
if (accountId == null) {
result.error("bad_args", "Missing accountId", null)
return
}
Thread {
try {
val manager = WorkManager.getInstance(applicationContext)
manager.cancelAllWorkByTag("noo_sync_account_$accountId").result.get()
manager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId)).result.get()
manager.cancelUniqueWork(SyncWorker.oneOffNameFor(accountId)).result.get()
manager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME).result.get()
runBlocking {
SyncEngine.syncLock.withLock {
SyncEngine.removeAccountData(applicationContext, accountId)
SyncStatusBus.clearAccount(accountId)
}
}
mainHandler.post { result.success(null) }
} catch (error: Exception) {
mainHandler.post { result.error("cleanup_failed", error.message, null) }
}
}.start()
}
private fun removeLocalSync(call: MethodCall, result: MethodChannel.Result) {
val accountId = call.argument<String>("accountId")
val path = call.argument<String>("path")
@@ -509,6 +509,16 @@ object SyncEngine {
* (see [diffFolder]'s `!localExists && !serverChanged` branch) instead
* of pulling it back down.
*/
fun removeAccountData(context: Context, accountId: String) {
val root = syncRoot(context, accountId)
check(!root.exists() || root.deleteRecursively()) { "Could not remove synced files" }
check(context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE).edit()
.remove(stateKey(accountId)).remove(rootsKey(accountId))
.remove(rootsVersionKey(accountId)).remove(missingKey(accountId)).commit()) {
"Could not remove sync records"
}
}
fun removeLocalSync(context: Context, accountId: String, path: String) {
val root = syncRoot(context, accountId)
val state = loadState(context, accountId).toMutableMap()
@@ -74,6 +74,16 @@ object SyncStatusBus {
publish()
}
@Synchronized
fun clearAccount(accountId: String) {
current = current.copy(
syncing = if (current.accountId == accountId) false else current.syncing,
syncingFileIds = if (current.accountId == accountId) emptySet() else current.syncingFileIds,
conflicts = current.conflicts.filter { it.accountId != accountId },
)
publish()
}
private fun publish() {
listeners.toList().forEach { it(current) }
}
+6
View File
@@ -607,6 +607,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
DEVELOPMENT_TEAM = Q3JLTAG9PV;
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -680,6 +681,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
DEVELOPMENT_TEAM = Q3JLTAG9PV;
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -708,6 +710,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
DEVELOPMENT_TEAM = Q3JLTAG9PV;
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -736,6 +739,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
DEVELOPMENT_TEAM = Q3JLTAG9PV;
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -870,6 +874,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
DEVELOPMENT_TEAM = Q3JLTAG9PV;
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -893,6 +898,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
DEVELOPMENT_TEAM = Q3JLTAG9PV;
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+12
View File
@@ -149,6 +149,18 @@ enum NativeServices {
// Fire and forget, like Android: the status stream reports the result.
Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) }
}
case "removeAccountData":
guard let accountId = args["accountId"] as? String else {
return result(FlutterError(code: "bad_args", message: "Missing accountId", details: nil))
}
Task {
do {
try await coordinator.removeAccountData(accountId: accountId)
await MainActor.run { result(nil) }
} catch {
await MainActor.run { result(FlutterError(code: "cleanup_failed", message: error.localizedDescription, details: nil)) }
}
}
case "removeLocalSync":
guard let accountId = args["accountId"] as? String, let path = args["path"] as? String else {
return result(FlutterError(code: "bad_args", message: "Missing required arguments", details: nil))
@@ -194,6 +194,16 @@ final class SyncCoordinator {
/// removal under the same mutex as runs - a run still holding the old
/// state would otherwise write it back over the removal (and a transfer
/// finishing after it would leave a file the state doesn't know about).
func removeAccountData(accountId: String) async throws {
cancel(accountId: accountId, forget: true)
try await mutex.withLock {
try store.removeAccountData(accountId: accountId)
for conflict in bus.snapshot().conflicts where conflict.accountId == accountId {
bus.removeConflict(accountId: accountId, fileId: conflict.fileId)
}
}
}
func removeLocalSync(accountId: String, path: String) async {
cancelRun(accountId: accountId, invalidateQueued: true)
_ = try? await mutex.withLock {
@@ -108,6 +108,13 @@ final class SyncStore {
if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) }
}
func removeAccountData(accountId: String) throws {
for directory in ["sync", "sync-state"] {
let url = base.appendingPathComponent(directory).appendingPathComponent(accountId)
if fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) }
}
}
// MARK: - Removing a path's mirror
/// Deletes [path]'s local mirror (a file, or a whole folder's worth) and
+15
View File
@@ -237,6 +237,21 @@ final class SyncEngineTests: XCTestCase {
// MARK: - The happy path
func testRemovingAccountDeletesMirrorAndStateButPreservesOtherAccounts() async throws {
seedServer()
_ = await runner.run(config, force: false)
let other = store.syncRoot(accountId: "other").appendingPathComponent("keep.txt")
try Data("keep".utf8).write(to: other)
let mirrorPath = store.base.appendingPathComponent("sync/acct")
let statePath = store.base.appendingPathComponent("sync-state/acct")
try store.removeAccountData(accountId: "acct")
XCTAssertFalse(FileManager.default.fileExists(atPath: mirrorPath.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: statePath.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: other.path))
XCTAssertTrue(try store.loadState(accountId: "acct").isEmpty)
try store.removeAccountData(accountId: "acct")
}
func testFirstSyncDownloadsEverythingAndMirrorsFolders() async {
seedServer()
FakeDav.mkdir("/Docs/empty")
+40 -16
View File
@@ -35,8 +35,8 @@ import 'widgets/app_top_bar.dart';
import 'widgets/avatar_menu.dart';
import 'widgets/bottom_nav_bar.dart';
import 'widgets/create_menu.dart';
import 'widgets/noo/core/noo_avatar.dart';
import 'widgets/noo/core/noo_button.dart';
import 'widgets/noo/core/noo_pointer_selection.dart';
import 'widgets/noo/core/noo_fab.dart';
import 'widgets/noo/nav/noo_bottom_bar.dart';
import 'widgets/noo/nav/noo_sidebar.dart';
@@ -44,6 +44,7 @@ import 'widgets/noo/nav/noo_toolbar.dart';
import 'widgets/noo/noo_layout.dart';
import 'widgets/noo/overlays/noo_dialog.dart';
import 'widgets/shell/shell_common.dart';
import 'widgets/shell/tablet_account_menu.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
@@ -135,6 +136,7 @@ class NextcloudApp extends StatelessWidget {
return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) {
return MaterialApp(
builder: (context, child) => NooPointerSelection(child: child!),
title: 'Noo',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(
@@ -209,7 +211,13 @@ class MainShellView extends StatefulWidget {
State<MainShellView> createState() => _MainShellViewState();
}
class _MainShellViewState extends State<MainShellView> {
class _MainShellViewState extends State<MainShellView>
with SingleTickerProviderStateMixin {
late final AnimationController _paneTransition = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
value: 1,
);
late AppTab _currentTab;
final Set<AppTab> _selectionActiveTabs = {};
late final Map<AppTab, ScrollController> _scrollControllers;
@@ -329,6 +337,7 @@ class _MainShellViewState extends State<MainShellView> {
_shareSub?.cancel();
_pickSub?.cancel();
_shareAccountSync?.dispose();
_paneTransition.dispose();
super.dispose();
}
@@ -338,7 +347,6 @@ class _MainShellViewState extends State<MainShellView> {
final settings = context.watch<SettingsController>();
final pick = context.watch<PickController>();
final connectivity = context.watch<ConnectivityController>();
final session = context.watch<SessionController>();
final quota = context.watch<FilesController>().quota;
// Cheap and already loaded once the Trash tab has been visited this
// session - not worth adding a fetch just to populate a nav badge.
@@ -397,6 +405,10 @@ class _MainShellViewState extends State<MainShellView> {
void selectTab(AppTab tab) {
if (tab == _currentTab) return;
if (NooLayout.isDesktop(context) &&
!MediaQuery.disableAnimationsOf(context)) {
_paneTransition.forward(from: 0);
}
setState(() => _currentTab = tab);
}
@@ -486,16 +498,9 @@ class _MainShellViewState extends State<MainShellView> {
child: Row(
children: [
NooSidebar(
account: NooSidebarAccount(
avatar: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 32,
),
name: session.username,
subtitle: serverHost(session.serverUrl),
onTap: () => showAccountSwitcher(context),
),
account: const TabletAccountMenu(),
search: const ShellSearchLauncher(onSurface: true),
groupItems: true,
items: [
for (final tab in pinnedTabs)
NooSidebarItem(
@@ -529,18 +534,37 @@ class _MainShellViewState extends State<MainShellView> {
children: [
NooToolbar(
title: selectedTab.label,
search: const ShellSearchLauncher(onSurface: true),
backgroundColor: colors.bg,
framed: false,
actions: [
if (canUpload)
NooButton(
icon: LucideIcons.upload,
icon: LucideIcons.plus,
onTap: () => showCreateMenu(context),
child: const Text('Upload'),
),
],
),
Expanded(
child: ColoredBox(color: colors.surface, child: tabStack),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: ColoredBox(
color: colors.surface2,
child: FadeTransition(
opacity: Tween<double>(begin: .65, end: 1)
.animate(
CurvedAnimation(
parent: _paneTransition,
curve: Curves.easeOutCubic,
),
),
child: SizedBox.expand(child: tabStack),
),
),
),
),
),
],
),
+7
View File
@@ -5,11 +5,16 @@ class SavedAccount {
final String id;
final String serverUrl;
final String username;
final String? displayName;
String get label =>
displayName?.trim().isNotEmpty == true ? displayName!.trim() : username;
const SavedAccount({
required this.id,
required this.serverUrl,
required this.username,
this.displayName,
});
/// A deterministic id from the server + username, so re-adding the same
@@ -25,11 +30,13 @@ class SavedAccount {
'id': id,
'serverUrl': serverUrl,
'username': username,
if (displayName != null) 'displayName': displayName,
};
factory SavedAccount.fromJson(Map<String, dynamic> json) => SavedAccount(
id: json['id'] as String,
serverUrl: json['serverUrl'] as String,
username: json['username'] as String,
displayName: json['displayName'] as String?,
);
}
+4 -1
View File
@@ -470,7 +470,10 @@ class FilesController extends ChangeNotifier
'[FilesController] Loaded ${_items.length} items for $_currentFolderPath',
);
try {
_quota = await service.fetchUserQuota();
final quota = await service.fetchUserQuota();
if (gen != session.sessionGeneration) return;
_quota = quota;
await session.cacheDisplayName(quota.userName, generation: gen);
} catch (e) {
debugPrint('[FilesController] Quota fetch warning: $e');
}
+35 -2
View File
@@ -171,6 +171,39 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
// Getters
String get serverUrl => _serverUrl;
String get username => _username;
String get displayName =>
_accounts
.where((a) => a.id == activeAccountId)
.map((a) => a.label)
.firstOrNull ??
_username;
Future<void> cacheDisplayName(String name, {required int generation}) async {
final clean = name.trim();
if (generation != _sessionGeneration || clean.isEmpty) return;
final id = activeAccountId;
if (id == null ||
!_accounts.any((a) => a.id == id && a.displayName != clean)) {
return;
}
final prefs = await prefsFuture;
if (generation != _sessionGeneration) return;
_accounts = [
for (final a in _accounts)
if (a.id == id)
SavedAccount(
id: a.id,
serverUrl: a.serverUrl,
username: a.username,
displayName: clean,
)
else
a,
];
await accountStore.saveAccounts(prefs, _accounts);
notifyListeners();
}
bool get isLoggedIn => _isLoggedIn;
bool get isLoading => _isLoading;
bool get isRestoringSession => _isRestoringSession;
@@ -560,6 +593,8 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
if (index == -1) return;
final wasActive = accountId == _activeAccountId;
await SyncService.removeAccountData(accountId);
_accounts = [..._accounts]..removeAt(index);
final prefs = await prefsFuture;
await accountStore.saveAccounts(prefs, _accounts);
@@ -568,8 +603,6 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
await prefs.remove(accountStore.accountPrefKey(accountId, key));
}
await accountStore.setSignedOut(prefs, accountId, false);
// Its background sync job holds that account's credentials - stop it.
unawaited(SyncService.cancelAccount(accountId));
if (!wasActive) {
notifyListeners();
+4 -1
View File
@@ -12,6 +12,7 @@ class ShareAccountEntry {
final String id;
final String serverUrl;
final String username;
final String? displayName;
final String password;
/// The account's Files "hidden files" filter: `hide`, `only` or `include`.
@@ -24,6 +25,7 @@ class ShareAccountEntry {
required this.id,
required this.serverUrl,
required this.username,
this.displayName,
required this.password,
required this.hiddenFilter,
required this.storageScope,
@@ -60,7 +62,7 @@ class ShareAccountService {
'username': a.username,
'authHeader': basicAuth(a.username, a.password),
'displayName':
'${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
'${a.displayName?.trim().isNotEmpty == true ? a.displayName!.trim() : a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
'hiddenFilter': a.hiddenFilter,
'storageScope': a.storageScope,
},
@@ -119,6 +121,7 @@ class ShareAccountService {
id: account.id,
serverUrl: account.serverUrl,
username: account.username,
displayName: account.label,
password: password,
hiddenFilter: filter.name,
storageScope: scope.name,
+12 -1
View File
@@ -132,6 +132,14 @@ class SyncService {
}
}
/// Deletes the removed account's mirror and native sync state after stopping
/// its runs. Await before forgetting the account so cleanup failures can retry.
static Future<void> removeAccountData(String accountId) async {
await invokeIfAvailable(_channel, 'removeAccountData', {
'accountId': accountId,
});
}
static String _basicAuth(SavedAccount account, String password) =>
'Basic ${base64Encode(utf8.encode('${account.username}:$password'))}';
@@ -143,7 +151,10 @@ class SyncService {
/// background job is being turned off - the account's paths are still
/// synced by hand ("Sync now"), and a conflict notification's actions
/// still need its credentials.
static Future<void> cancelAccount(String accountId, {bool forget = true}) async {
static Future<void> cancelAccount(
String accountId, {
bool forget = true,
}) async {
await invokeIfAvailable(_channel, 'cancel', {
'accountId': accountId,
'forget': forget,
+120 -33
View File
@@ -5,9 +5,11 @@ import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/nav/noo_sidebar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/settings/settings_account_card.dart';
import '../widgets/settings/settings_accounts.dart';
import '../widgets/settings/settings_about.dart';
import '../widgets/settings/settings_action_bar.dart';
import '../widgets/settings/settings_appearance.dart';
import '../widgets/settings/settings_file_sync.dart';
@@ -15,35 +17,20 @@ import '../widgets/settings/settings_files_cache.dart';
import '../widgets/settings/settings_security.dart';
import '../widgets/settings/settings_swipe.dart';
import '../widgets/settings/settings_tabs.dart';
import '../widgets/settings/settings_section.dart';
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
/// order: account card, accounts, security, file sync, files cache,
/// appearance, tabs, action bar, swipe on a file).
///
/// Every layout (including wide tablet, in a centered 640px column) is
/// a two-level menu, the way native iOS/Android Settings apps
/// work: [SettingsAccountCard] (the account summary, not a settings picker)
/// stays pinned at the top of a single top-level list ([_MobileMenu]), and
/// every other section becomes one tappable [NooSettingsRow] - icon, title,
/// chevron - in a [NooGroupedList] below it. Tapping a row pushes a new
/// [_SettingsCategoryScreen] with its own [NooTopBar]/[NooTopBarBack],
/// containing just that section's content full-screen. This replaces an
/// earlier design where every section rendered inline in one long
/// collapsible-sections column (`_MobileList`, since removed) - and before
/// that, a trailing jump rail (an even earlier version) that scrolled that
/// *same* page to an anchor. Both were rejected: the jump rail added a
/// second, redundant way to navigate on top of plain scrolling without
/// shortening the page, and the collapsible-sections column still left a
/// long page to scroll past even collapsed. A genuinely separate pushed
/// screen per category removes the scroll-depth problem outright, so
/// neither a jump rail nor per-section collapsing is needed any more - see
/// [SettingsSection]'s doc comment for how that reflects in its mobile
/// layout. See each `widgets/settings/*.dart` file for a section's own
/// content and any setting that had to be slotted in or grouped under
/// "Advanced appearance".
class AccountView extends StatelessWidget {
/// Settings uses pushed category screens on phones and an inline category
/// sidebar with a rounded content pane on tablets.
class AccountView extends StatefulWidget {
const AccountView({super.key});
@override
State<AccountView> createState() => _AccountViewState();
}
class _AccountViewState extends State<AccountView> {
int selected = 0;
static final _categories = <_SettingsCategory>[
_SettingsCategory(
title: 'Accounts',
@@ -85,6 +72,11 @@ class AccountView extends StatelessWidget {
icon: LucideIcons.chevronsLeftRight,
builder: (_) => const SettingsSwipeSection(),
),
_SettingsCategory(
title: 'About',
icon: LucideIcons.info,
builder: (_) => const SettingsAboutSection(),
),
];
@override
@@ -92,6 +84,101 @@ class AccountView extends StatelessWidget {
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
if (desktop) {
final category = _categories[selected];
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 260);
return Scaffold(
backgroundColor: colors.bg,
body: SafeArea(
child: Row(
children: [
NooSidebar(
windowControls: Row(
children: [
IconButton(
icon: const Icon(LucideIcons.arrowLeft),
tooltip: 'Back',
onPressed: () => Navigator.maybePop(context),
),
Expanded(
child: Text(
'Settings',
style: NooText.title.copyWith(color: colors.fg1),
),
),
],
),
account: SettingsAccountCard(
compact: true,
onManage: () => setState(() => selected = 0),
),
groupItems: true,
items: [
for (var index = 0; index < _categories.length; index++)
if (_categories[index].title != 'Swipe on a file')
NooSidebarItem(
icon: _categories[index].icon,
label: _categories[index].title,
selected: selected == index,
onTap: () => setState(() => selected = index),
),
],
),
Expanded(
child: Column(
children: [
NooToolbar(
title: category.title,
backgroundColor: colors.bg,
framed: false,
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
child: Material(
color: colors.surface2,
borderRadius: BorderRadius.circular(NooRadii.card),
clipBehavior: Clip.antiAlias,
child: AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) =>
FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(
begin: const Offset(0, .025),
end: Offset.zero,
).animate(animation),
child: child,
),
),
child: ListView(
key: ValueKey(category.title),
padding: const EdgeInsets.all(12),
children: [
SettingsCategoryHeading(
title: category.title,
child: category.builder(context),
),
],
),
),
),
),
),
],
),
),
],
),
),
);
}
return Scaffold(
backgroundColor: colors.bg,
appBar: desktop
@@ -184,12 +271,7 @@ class _MobileMenu extends StatelessWidget {
}
}
/// A pushed, single-category Settings screen: [NooTopBar] titled with the
/// category, a back button, and just that section's own content - the
/// pushed half of [AccountView]'s mobile menu-then-screen pattern. Always
/// built in a mobile-width context (desktop never opens this screen; it
/// shows every section inline in its own grid instead), so the section
/// widgets inside render their normal mobile layout unchanged.
/// A pushed category screen used by phone navigation.
class _SettingsCategoryScreen extends StatelessWidget {
final _SettingsCategory category;
const _SettingsCategoryScreen({required this.category});
@@ -223,7 +305,12 @@ class _SettingsCategoryScreen extends StatelessWidget {
NooSpace.xxl,
),
physics: const BouncingScrollPhysics(),
children: [category.builder(context)],
children: [
SettingsCategoryHeading(
title: category.title,
child: category.builder(context),
),
],
),
),
),
+2 -13
View File
@@ -41,7 +41,6 @@ class ActivityView extends StatelessWidget {
final files = context.watch<FilesController>();
final session = context.watch<SessionController>();
final activities = files.activities;
final isDesktop = NooLayout.isDesktop(context);
final groups = groupByCalendarDay<NextcloudActivity>(
activities,
(a) => a.timestamp,
@@ -116,22 +115,12 @@ class ActivityView extends StatelessWidget {
);
return ColoredBox(
color: colors.bg,
color: NooLayout.contentBackground(context),
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () => context.read<FilesController>().refreshData(),
// "Desktop limits it to 760px wide" (DESIGN_SYSTEM.md §4) - centered
// rather than left-aligned, so a wide window doesn't stretch the
// feed's short sentences edge to edge.
child: isDesktop
? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: scrollView,
),
)
: scrollView,
child: scrollView,
),
);
}
+5 -9
View File
@@ -400,6 +400,7 @@ class _FavoritesViewState extends State<FavoritesView> {
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
selected: isSelected,
onSelectionToggle: () => _toggleSelection(item),
thumbnail: _thumbnailFor(context, item, extent: NooSizes.rowMobile),
onTap: () {
if (_isSelecting) {
@@ -434,6 +435,7 @@ class _FavoritesViewState extends State<FavoritesView> {
col3: item.isFolder ? null : formatBytes(item.size),
favorite: item.isFavorite,
selected: isSelected,
onSelectionToggle: () => _toggleSelection(item),
thumbnail: _thumbnailFor(
context,
item,
@@ -477,6 +479,7 @@ class _FavoritesViewState extends State<FavoritesView> {
thumbnailHeight: thumbnailHeight,
thumbnail: _thumbnailFor(context, item, extent: thumbnailHeight),
selected: isSelected,
onSelectionToggle: () => _toggleSelection(item),
verticalOverflowIcon: !NooLayout.iosStyle(context),
onTap: () {
if (_isSelecting) {
@@ -600,14 +603,7 @@ class _FavoritesViewState extends State<FavoritesView> {
vertical: NooSpace.xs,
),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isDesktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
crossAxisSpacing: isDesktop ? 16 : 10,
mainAxisSpacing: isDesktop ? 16 : 10,
childAspectRatio: isDesktop ? 0.92 : 0.85,
),
gridDelegate: NooLayout.fileGridDelegate(context),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(
context,
@@ -680,7 +676,7 @@ class _FavoritesViewState extends State<FavoritesView> {
? _buildSelectionBar(context, selectedItems)
: null,
child: ColoredBox(
color: colors.bg,
color: NooLayout.contentBackground(context),
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
+1 -3
View File
@@ -729,9 +729,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
default:
if (_isPdf) {
return MediaPdfPreview(
topInset: _controlsVisible
? MediaQuery.paddingOf(context).top + 60
: 0,
topInset: MediaQuery.paddingOf(context).top + 60,
item: widget.item,
session: session,
localPathResolver: widget.localPathResolver,
+24 -17
View File
@@ -745,14 +745,7 @@ class _FilesViewState extends State<FilesView> {
NooSpace.lg,
),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isDesktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
childAspectRatio: isDesktop ? 1.05 : 0.92,
crossAxisSpacing: isDesktop ? 16 : 10,
mainAxisSpacing: isDesktop ? 16 : 10,
),
gridDelegate: NooLayout.fileGridDelegate(context),
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
return _FolderEnterAnimation(
@@ -971,7 +964,7 @@ class _FilesViewState extends State<FilesView> {
? _buildSelectionBar(context, selectedItems)
: null,
child: ColoredBox(
color: colors.bg,
color: NooLayout.contentBackground(context),
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
@@ -1061,14 +1054,16 @@ class _FilesViewState extends State<FilesView> {
tone: hasConflicts
? NooSummaryCardTone.danger
: NooSummaryCardTone.normal,
action: NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.compact,
icon: LucideIcons.refreshCw,
disabled: sync.isSyncingNow,
onTap: () => sync.syncOnPull(),
child: const Text('Sync now'),
),
action: sync.syncEverything || sync.syncedPaths.isNotEmpty
? NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.compact,
icon: LucideIcons.refreshCw,
disabled: sync.isSyncingNow,
onTap: () => sync.syncOnPull(),
child: const Text('Sync now'),
)
: null,
),
);
}
@@ -1360,6 +1355,10 @@ class _FilesViewState extends State<FilesView> {
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
selected: isSelected,
onSelectionToggle:
_offline || (picking && !pick.pickRequest!.allowMultiple)
? null
: () => _toggleSelection(item),
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.row),
onTap: () => _handleItemTap(context, item, picking: picking),
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
@@ -1447,6 +1446,10 @@ class _FilesViewState extends State<FilesView> {
statuses: _nooStatuses(status),
favorite: item.isFavorite,
selected: isSelected,
onSelectionToggle:
_offline || (picking && !pick.pickRequest!.allowMultiple)
? null
: () => _toggleSelection(item),
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop),
onTap: () => _handleItemTap(context, item, picking: picking),
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
@@ -1549,6 +1552,10 @@ class _FilesViewState extends State<FilesView> {
: null,
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
selected: isSelected,
onSelectionToggle:
_offline || (picking && !pick.pickRequest!.allowMultiple)
? null
: () => _toggleSelection(item),
onTap: () => _handleItemTap(context, item, picking: picking),
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
? null
+62 -19
View File
@@ -37,7 +37,9 @@ class _LoginViewState extends State<LoginView> {
void initState() {
super.initState();
if (widget.isAddingAccount) {
_originalActiveAccountId = context.read<SessionController>().activeAccountId;
_originalActiveAccountId = context
.read<SessionController>()
.activeAccountId;
}
}
@@ -50,12 +52,17 @@ class _LoginViewState extends State<LoginView> {
void _handleContinue() {
final url = _urlController.text.trim();
if (url.isEmpty) {
setState(() => _localError = 'Please enter your Nextcloud server address');
setState(
() => _localError = 'Please enter your Nextcloud server address',
);
return;
}
setState(() => _localError = null);
FocusScope.of(context).unfocus();
context.read<SessionController>().startLoginFlow(url, addAccount: widget.isAddingAccount);
context.read<SessionController>().startLoginFlow(
url,
addAccount: widget.isAddingAccount,
);
}
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
@@ -73,7 +80,7 @@ class _LoginViewState extends State<LoginView> {
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text("Couldn't sign in as ${account.username}"),
content: Text("Couldn't sign in as ${account.label}"),
behavior: SnackBarBehavior.floating,
),
);
@@ -97,7 +104,8 @@ class _LoginViewState extends State<LoginView> {
final colors = context.nooColors;
final session = context.watch<SessionController>();
final isAwaitingBrowser = session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isAwaitingBrowser =
session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
// A new/refreshed account has just become active - pop back to
@@ -117,7 +125,9 @@ class _LoginViewState extends State<LoginView> {
// SessionController.startLoginFlow's doc comment for why. Push it the
// moment there's a URL to show; _webViewPushed resets once that route
// pops (cancelled or done) so a retry after cancelling pushes it again.
if (!_webViewPushed && isAwaitingBrowser && session.pendingLoginUrl != null) {
if (!_webViewPushed &&
isAwaitingBrowser &&
session.pendingLoginUrl != null) {
_webViewPushed = true;
final url = session.pendingLoginUrl!;
WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -142,7 +152,10 @@ class _LoginViewState extends State<LoginView> {
}
final errorMessage =
_localError ?? (session.loginFlowStatus == LoginFlowStatus.error ? session.errorMessage : null);
_localError ??
(session.loginFlowStatus == LoginFlowStatus.error
? session.errorMessage
: null);
final body = Scaffold(
backgroundColor: colors.bg,
@@ -167,7 +180,8 @@ class _LoginViewState extends State<LoginView> {
// deleting it, specifically so it can be resumed
// from here with one tap - no need to repeat
// Login Flow v2.
if (!widget.isAddingAccount && session.accounts.isNotEmpty) ...[
if (!widget.isAddingAccount &&
session.accounts.isNotEmpty) ...[
_SavedAccountsSection(
accounts: session.accounts,
onSelect: (account) => _continueAsAccount(account),
@@ -179,7 +193,10 @@ class _LoginViewState extends State<LoginView> {
Expanded(child: Divider(color: colors.line)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text('or', style: NooText.meta.copyWith(color: colors.fg3)),
child: Text(
'or',
style: NooText.meta.copyWith(color: colors.fg3),
),
),
Expanded(child: Divider(color: colors.line)),
],
@@ -188,7 +205,8 @@ class _LoginViewState extends State<LoginView> {
],
_ServerForm(
urlController: _urlController,
isLoading: isInitiating || session.isLoading || isAwaitingBrowser,
isLoading:
isInitiating || session.isLoading || isAwaitingBrowser,
errorMessage: errorMessage,
onContinue: _handleContinue,
),
@@ -246,11 +264,19 @@ class _ServerForm extends StatelessWidget {
// in-app, rather than the full-color launcher icon.
child: ColorFiltered(
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
child: Image.asset('assets/icon/app_icon_monochrome.png', width: 80, height: 80),
child: Image.asset(
'assets/icon/app_icon_monochrome.png',
width: 80,
height: 80,
),
),
),
const SizedBox(height: 24),
Text('Noo', textAlign: TextAlign.center, style: NooText.largeTitle.copyWith(color: colors.fg1)),
Text(
'Noo',
textAlign: TextAlign.center,
style: NooText.largeTitle.copyWith(color: colors.fg1),
),
const SizedBox(height: 6),
Text(
'Connect to your self-hosted server',
@@ -317,7 +343,11 @@ class _SavedAccountsSection extends StatelessWidget {
final ValueChanged<SavedAccount> onSelect;
final ValueChanged<SavedAccount> onRemove;
const _SavedAccountsSection({required this.accounts, required this.onSelect, required this.onRemove});
const _SavedAccountsSection({
required this.accounts,
required this.onSelect,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
@@ -328,7 +358,10 @@ class _SavedAccountsSection extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text('Continue as', style: NooText.label.copyWith(color: colors.fg2)),
child: Text(
'Continue as',
style: NooText.label.copyWith(color: colors.fg2),
),
),
ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
@@ -358,7 +391,11 @@ class _SavedAccountRow extends StatelessWidget {
final VoidCallback onTap;
final VoidCallback onRemove;
const _SavedAccountRow({required this.account, required this.onTap, required this.onRemove});
const _SavedAccountRow({
required this.account,
required this.onTap,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
@@ -369,18 +406,21 @@ class _SavedAccountRow extends StatelessWidget {
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: 10,
),
child: Row(
spacing: 12,
children: [
NooAvatar(initials: accountInitial(account.username), size: 36),
NooAvatar(initials: accountInitial(account.label), size: 36),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: [
Text(
account.username,
account.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
@@ -404,7 +444,10 @@ class _SavedAccountRow extends StatelessWidget {
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
decoration: BoxDecoration(
color: colors.surface2,
shape: BoxShape.circle,
),
child: Icon(LucideIcons.x, size: 16, color: colors.fg2),
),
),
+1 -8
View File
@@ -283,14 +283,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: desktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10,
),
gridDelegate: NooLayout.fileGridDelegate(context),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildGridCard(folders[index]),
childCount: folders.length,
+5 -8
View File
@@ -355,13 +355,7 @@ class _PhotosViewState extends State<PhotosView> {
),
NooPhotoGrid(
itemCount: group.items.length,
columns: isDesktop
? NooPhotoGrid.desktopColumns
: NooLayout.gridColumns(
context,
phone: NooPhotoGrid.mobileColumns,
minTile: 130,
),
minTileWidth: 150,
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
@@ -393,7 +387,7 @@ class _PhotosViewState extends State<PhotosView> {
? _buildSelectionBar(context, pick, selectedItems)
: null,
child: ColoredBox(
color: colors.bg,
color: NooLayout.contentBackground(context),
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
@@ -653,6 +647,9 @@ class _PhotosViewState extends State<PhotosView> {
return NooPhotoTile(
selected: isSelected,
onSelectionToggle: pick.isPicking && !pick.pickRequest!.allowMultiple
? null
: () => _toggleSelection(photo),
selectionMode: _isSelecting,
// No real duration source (no video-metadata extraction anywhere in
// the app) - an empty string still marks it as a video and shows the
+1 -1
View File
@@ -83,7 +83,7 @@ class _RecentViewState extends State<RecentView> {
];
return ColoredBox(
color: colors.bg,
color: NooLayout.contentBackground(context),
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
+24 -13
View File
@@ -123,18 +123,24 @@ class _SearchViewState extends State<SearchView> {
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),
),
? PreferredSize(
preferredSize: const Size.fromHeight(NooToolbar.outerHeight),
child: SafeArea(
bottom: false,
child: 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(
@@ -223,7 +229,9 @@ class _SearchViewState extends State<SearchView> {
title: 'No results found',
);
}
return desktop ? _buildDesktopResults(context) : _buildMobileResults(context);
return desktop
? _buildDesktopResults(context)
: _buildMobileResults(context);
}
Widget _buildMessage(
@@ -304,7 +312,10 @@ class _SearchViewState extends State<SearchView> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
const NooFileTableHeader(
col2Label: 'Modified',
col3Label: 'Location',
),
for (final item in _results)
NooFileTableRow(
kind: NooFileKind.from(
+1 -8
View File
@@ -208,14 +208,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: desktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10,
),
gridDelegate: NooLayout.fileGridDelegate(context),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildGridCard(context, folders[index]),
childCount: folders.length,
+1 -1
View File
@@ -129,7 +129,7 @@ class _SharesViewState extends State<SharesView> {
];
return ColoredBox(
color: colors.bg,
color: NooLayout.contentBackground(context),
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
+25 -91
View File
@@ -9,7 +9,6 @@ import '../widgets/noo/core/noo_button.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/files/noo_file_tile.dart';
import '../widgets/noo/lists/noo_banner.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_location.dart';
@@ -98,7 +97,7 @@ class _TrashViewState extends State<TrashView> {
];
return ColoredBox(
color: colors.bg,
color: NooLayout.contentBackground(context),
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
@@ -335,17 +334,7 @@ class _TrashViewState extends State<TrashView> {
}
}
/// Desktop trash row: like `NooFileTableRow`, but its 120px last column
/// holds a tonal "Restore" button plus a danger-tonal "Delete" button
/// (permanent delete) instead of status icons - `NooFileTableRow` has no
/// slot for screen-specific actions there. A labelled pill, not a bare
/// overflow icon: it's the row's only destructive action, not a menu of
/// several, and desktop already prefers labelled buttons to bare icons
/// (see `NooSelectionBar`'s own desktop actions). Column widths
/// (180/160/120) are matched by hand to `NooFileTableHeader`'s (private in
/// `noo_file_table.dart`) so this still lines up under it; promoting an
/// optional `actions` slot onto `NooFileTableRow` would let this fold back
/// into the shared component.
/// Trash actions use the same responsive columns as the other file tables.
class _TrashDesktopRow extends StatelessWidget {
final NooFileKind kind;
final String name;
@@ -364,83 +353,28 @@ class _TrashDesktopRow extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
return SizedBox(
height: NooSizes.rowDesktop,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: Row(
children: [
Expanded(
child: Row(
children: [
NooFileTile(kind: kind, size: NooFileTileSize.desktop),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
const SizedBox(width: NooSpace.md),
],
),
),
SizedBox(
width: 180,
child: Text(
col2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
),
),
SizedBox(
width: 160,
child: Text(
col3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
),
),
SizedBox(
width: 120,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.xs,
onTap: onRestore,
child: const Text('Restore'),
),
const SizedBox(width: 4),
NooButton(
variant: NooButtonVariant.danger,
size: NooButtonSize.xs,
onTap: onDelete,
child: const Text('Delete'),
),
],
),
),
),
],
Widget build(BuildContext context) => NooFileTableRow(
kind: kind,
name: name,
col2: col2,
col3: col3,
actions: Row(
mainAxisSize: MainAxisSize.min,
children: [
NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.xs,
onTap: onRestore,
child: const Text('Restore'),
),
),
);
}
const SizedBox(width: 4),
NooButton(
variant: NooButtonVariant.danger,
size: NooButtonSize.xs,
onTap: onDelete,
child: const Text('Delete'),
),
],
),
);
}
+2 -2
View File
@@ -45,11 +45,11 @@ class AppDrawer extends StatelessWidget {
return NooDrawer(
account: NooDrawerAccount(
avatar: NooAvatar(
initials: accountInitial(session.username),
initials: accountInitial(session.displayName),
current: true,
size: 48,
),
name: session.username,
name: session.displayName,
subtitle: serverHost(session.serverUrl),
onTap: closeAndOpenSettings,
onSwitchAccount: () => showAccountSwitcher(context),
+4 -4
View File
@@ -109,7 +109,7 @@ class AvatarMenuCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.username,
session.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
@@ -140,7 +140,7 @@ class AvatarMenuCard extends StatelessWidget {
onTap: onClose,
child: NooAvatar(
key: headerAvatarKey,
initials: accountInitial(session.username),
initials: accountInitial(session.displayName),
current: true,
size: 32,
),
@@ -159,7 +159,7 @@ class AvatarMenuCard extends StatelessWidget {
for (final account in session.accounts)
if (account.id != session.activeAccountId)
_OtherAccountRow(
name: account.username,
name: account.label,
host: serverHost(account.serverUrl),
onTap: () {
onClose();
@@ -667,7 +667,7 @@ class _AvatarNavigationHostState extends State<AvatarNavigationHost>
child: GestureDetector(
onTap: close,
child: NooAvatar(
initials: accountInitial(session.username),
initials: accountInitial(session.displayName),
current: true,
size: 32,
),
@@ -0,0 +1,83 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Tracks pointer input without assuming that a large screen has a mouse.
class NooPointerSelection extends StatefulWidget {
final Widget child;
const NooPointerSelection({super.key, required this.child});
static bool available(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_PointerScope>()?.available ??
false;
@override
State<NooPointerSelection> createState() => _NooPointerSelectionState();
}
class _NooPointerSelectionState extends State<NooPointerSelection> {
bool trackpad = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.mouseTracker.addListener(_refresh);
GestureBinding.instance.pointerRouter.addGlobalRoute(_pointer);
}
void _pointer(PointerEvent event) {
if (event.kind == PointerDeviceKind.trackpad && !trackpad) {
trackpad = true;
_refresh();
} else if (event.kind == PointerDeviceKind.touch && trackpad) {
trackpad = false;
_refresh();
}
}
void _refresh() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
WidgetsBinding.instance.scheduleFrame();
}
@override
void dispose() {
WidgetsBinding.instance.mouseTracker.removeListener(_refresh);
GestureBinding.instance.pointerRouter.removeGlobalRoute(_pointer);
super.dispose();
}
@override
Widget build(BuildContext context) => _PointerScope(
available:
trackpad || WidgetsBinding.instance.mouseTracker.mouseIsConnected,
child: widget.child,
);
}
class _PointerScope extends InheritedWidget {
final bool available;
const _PointerScope({required this.available, required super.child});
@override
bool updateShouldNotify(_PointerScope oldWidget) =>
available != oldWidget.available;
}
class NooPointerCheckbox extends StatelessWidget {
final bool selected;
final VoidCallback? onToggle;
const NooPointerCheckbox({super.key, required this.selected, this.onToggle});
@override
Widget build(BuildContext context) =>
onToggle == null || !NooPointerSelection.available(context)
? const SizedBox.shrink()
: SizedBox(
width: 40,
height: 40,
child: Checkbox(
value: selected,
onChanged: (_) => onToggle!(),
semanticLabel: selected ? 'Deselect item' : 'Select item',
),
);
}
+7
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../core/noo_pointer_selection.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
@@ -41,6 +42,7 @@ class NooFileRow extends StatelessWidget {
/// `ellipsis` on iOS, `ellipsis-vertical` everywhere else.
final bool iosStyle;
final bool selected;
final VoidCallback? onSelectionToggle;
/// Real image/video thumbnail for the tile; see [NooFileTile.thumbnail].
final Widget? thumbnail;
@@ -62,6 +64,7 @@ class NooFileRow extends StatelessWidget {
this.trailing,
this.iosStyle = false,
this.selected = false,
this.onSelectionToggle,
this.thumbnail,
this.onTap,
this.onLongPress,
@@ -93,6 +96,10 @@ class NooFileRow extends StatelessWidget {
),
child: Row(
children: [
NooPointerCheckbox(
selected: selected,
onToggle: onSelectionToggle,
),
NooFileTile(kind: kind, thumbnail: thumbnail),
const SizedBox(width: NooSpace.sm),
Expanded(
+61 -24
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../core/noo_pointer_selection.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
@@ -6,9 +7,8 @@ import 'noo_file_row.dart';
import 'noo_file_tile.dart';
import 'noo_status_icon.dart';
/// Fixed widths of the desktop file table's grid
/// (`minmax(0,1fr) 180px 160px 120px`, `DESIGN_SYSTEM.md` 2 "File row").
/// The first column takes the remaining width.
/// Maximum metadata widths for wide file tables. Compact panes collapse
/// the date column and reserve the remaining width for the filename.
const double _col2Width = 180;
const double _col3Width = 160;
const double _col4Width = 120;
@@ -36,13 +36,33 @@ class _TableColumns extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: _tableInset),
child: Row(
children: [
Expanded(child: name),
SizedBox(width: _col2Width, child: col2),
SizedBox(width: _col3Width, child: col3),
SizedBox(width: _col4Width, child: col4),
],
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final compact = width < 600;
final statusWidth = width < 420 ? 80.0 : _col4Width;
final metadataWidth = compact
? 72.0
: (width * .18).clamp(90.0, _col3Width);
final dateWidth = compact
? 0.0
: (width * .22).clamp(110.0, _col2Width);
return Row(
children: [
Expanded(child: name),
if (!compact) SizedBox(width: dateWidth, child: col2),
if (width >= 420) SizedBox(width: metadataWidth, child: col3),
SizedBox(
width: statusWidth,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: col4,
),
),
],
);
},
),
);
}
@@ -150,7 +170,11 @@ class NooFileTableRow extends StatelessWidget {
final List<NooSyncStatus> statuses;
final bool favorite;
final bool selected;
final VoidCallback? onSelectionToggle;
final Widget? thumbnail;
/// Optional last-column actions, laid out using the shared column widths.
final Widget? actions;
final VoidCallback? onTap;
final VoidCallback? onDoubleTap;
final VoidCallback? onSecondaryTap;
@@ -168,7 +192,9 @@ class NooFileTableRow extends StatelessWidget {
this.statuses = const [],
this.favorite = false,
this.selected = false,
this.onSelectionToggle,
this.thumbnail,
this.actions,
this.onTap,
this.onDoubleTap,
this.onSecondaryTap,
@@ -201,6 +227,10 @@ class NooFileTableRow extends StatelessWidget {
child: _TableColumns(
name: Row(
children: [
NooPointerCheckbox(
selected: selected,
onToggle: onSelectionToggle,
),
NooFileTile(
kind: kind,
size: NooFileTileSize.desktop,
@@ -224,20 +254,27 @@ class NooFileTableRow extends StatelessWidget {
),
col2: dataCell(col2),
col3: dataCell(col3),
col4: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final s in statuses) ...[
NooStatusIcon(s),
const SizedBox(width: 6),
],
if (favorite) ...[
Icon(LucideIcons.star, size: 14, color: colors.accentText),
const SizedBox(width: 6),
],
if (onMore != null) NooOverflowButton(size: 32, onTap: onMore),
],
),
col4:
actions ??
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final s in statuses) ...[
NooStatusIcon(s),
const SizedBox(width: 6),
],
if (favorite) ...[
Icon(
LucideIcons.star,
size: 14,
color: colors.accentText,
),
const SizedBox(width: 6),
],
if (onMore != null)
NooOverflowButton(size: 32, onTap: onMore),
],
),
),
),
),
+61 -30
View File
@@ -8,7 +8,7 @@ import 'noo_grouped_list.dart';
import 'noo_settings_row.dart';
/// Bulk actions shown inline before the rest collapse behind "More" - a
/// fixed, non-scrolling width on every platform (design canvas
/// capped, non-scrolling row adapted to the available pane width (design canvas
/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action
/// bar"). Mobile is tighter (icons share the bar with the close circle and
/// count on a ~360dp phone); desktop's labelled pills have a wider toolbar
@@ -34,9 +34,9 @@ const int _kDesktopInlineActions = 4;
/// presentation differ:
/// - Mobile: the first [_kMobileInlineActions] actions as plain 20px
/// accent-text icons, no fill.
/// - Desktop: the first [_kDesktopInlineActions] as labelled tonal pills -
/// - Wide panes: up to [_kDesktopInlineActions] as labelled tonal pills -
/// danger-soft/danger for the one labelled "Delete", accent-soft/
/// accent-text otherwise - since desktop has the room for labels.
/// accent-text otherwise. Narrow tablet panes use the icon presentation.
///
/// Any actions beyond that inline count sit behind a trailing "More"
/// button that opens the same grouped-list sheet a file row's own overflow
@@ -62,12 +62,43 @@ class NooSelectionBar extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) => _buildBar(context, constraints.maxWidth),
);
Widget _buildBar(BuildContext context, double width) {
final colors = context.nooColors;
final gutter = isDesktop ? NooSpace.xl : NooSpace.sm;
final inlineCount = isDesktop
final labelled =
isDesktop &&
width >= 700 &&
MediaQuery.textScalerOf(context).scale(14) <= 18;
var inlineCount = labelled
? _kDesktopInlineActions
: _kMobileInlineActions;
: ((width - gutter * 2 - 12 - 140 - 32) / 40).floor().clamp(
0,
_kMobileInlineActions,
);
if (labelled) {
var used = 0.0;
final budget = width - gutter * 2 - 12 - 140 - 85;
inlineCount = 0;
for (final action in actions.take(_kDesktopInlineActions)) {
final text = TextPainter(
text: TextSpan(
text: action.label,
style: NooText.buttonSm.copyWith(fontSize: 13),
),
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout();
final pillWidth = text.width + 51;
text.dispose();
if (used + pillWidth > budget) break;
used += pillWidth;
inlineCount++;
}
}
final inlineActions = actions.length > inlineCount
? actions.sublist(0, inlineCount)
: actions;
@@ -111,33 +142,33 @@ class NooSelectionBar extends StatelessWidget {
),
),
const SizedBox(width: NooSpace.xs),
Text(
'$count selected',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.cardTitle.copyWith(
fontSize: 17,
color: colors.fg1,
Expanded(
child: Text(
'$count selected',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.cardTitle.copyWith(
fontSize: 17,
color: colors.fg1,
),
),
),
const SizedBox(width: NooSpace.xs),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: isDesktop
? _desktopActions(
context,
colors,
inlineActions,
overflowActions,
)
: _mobileActions(
context,
colors,
inlineActions,
overflowActions,
),
),
Align(
alignment: Alignment.centerRight,
child: labelled
? _desktopActions(
context,
colors,
inlineActions,
overflowActions,
)
: _mobileActions(
context,
colors,
inlineActions,
overflowActions,
),
),
],
),
+17 -2
View File
@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import '../core/noo_pointer_selection.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Grid-view file card: a thumbnail area (file-type soft colour + 32px
/// icon, or a real [thumbnail]) above the name, an overflow button and a
/// meta line (`DESIGN_SYSTEM.md` section 2, "Grid card"). Mobile lays these
/// out 2-up with a 10px gap, desktop 5-up with a 16px gap - the parent grid
/// out with 10px phone / 16px tablet gaps and responsive columns - the parent grid
/// owns that, this card just fills its cell.
///
/// The placeholder colours/icon are plain parameters rather than a file
@@ -30,6 +31,7 @@ class NooGridCard extends StatelessWidget {
final double thumbnailHeight;
final bool selected;
final VoidCallback? onSelectionToggle;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
@@ -49,6 +51,7 @@ class NooGridCard extends StatelessWidget {
this.thumbnail,
this.thumbnailHeight = 104,
this.selected = false,
this.onSelectionToggle,
this.onTap,
this.onLongPress,
this.onMore,
@@ -88,7 +91,19 @@ class NooGridCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
thumb,
Stack(
children: [
thumb,
Positioned(
top: 4,
left: 4,
child: NooPointerCheckbox(
selected: selected,
onToggle: onSelectionToggle,
),
),
],
),
Padding(
padding: EdgeInsets.fromLTRB(
NooSpace.sm,
+18 -7
View File
@@ -40,7 +40,10 @@ class NooPhotoGroupHeader extends StatelessWidget {
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.groupHeading.copyWith(height: 1, color: colors.fg1),
style: NooText.groupHeading.copyWith(
height: 1,
color: colors.fg1,
),
),
),
if (count != null) ...[
@@ -67,6 +70,7 @@ class NooPhotoGrid extends StatelessWidget {
final IndexedWidgetBuilder itemBuilder;
final int columns;
final double gap;
final double? minTileWidth;
final EdgeInsetsGeometry padding;
final bool _sliver;
@@ -77,6 +81,7 @@ class NooPhotoGrid extends StatelessWidget {
required this.itemBuilder,
this.columns = 3,
this.gap = 2,
this.minTileWidth,
this.padding = EdgeInsets.zero,
}) : _sliver = true;
@@ -87,6 +92,7 @@ class NooPhotoGrid extends StatelessWidget {
required this.itemBuilder,
this.columns = 3,
this.gap = 2,
this.minTileWidth,
this.padding = EdgeInsets.zero,
}) : _sliver = false;
@@ -96,12 +102,17 @@ class NooPhotoGrid extends StatelessWidget {
static const desktopColumns = 8;
static const desktopGap = 4.0;
SliverGridDelegate get _delegate =>
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
);
SliverGridDelegate get _delegate => minTileWidth != null
? SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: minTileWidth!,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
)
: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
);
@override
Widget build(BuildContext context) {
+19 -6
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../core/noo_pointer_selection.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
@@ -25,6 +26,7 @@ class NooPhotoTile extends StatelessWidget {
final String? videoDuration;
final bool selected;
final VoidCallback? onSelectionToggle;
/// Shows the selection check circle even when not [selected] (i.e. the
/// grid is in multi-select mode).
@@ -39,6 +41,7 @@ class NooPhotoTile extends StatelessWidget {
this.placeholderColor,
this.videoDuration,
this.selected = false,
this.onSelectionToggle,
this.selectionMode = false,
this.onTap,
this.onLongPress,
@@ -63,7 +66,9 @@ class NooPhotoTile extends StatelessWidget {
children: [
// Selected tiles inset so an accent-soft frame shows around
// the photo, instead of covering it with a tint.
ColoredBox(color: selected ? colors.accentSoft : Colors.transparent),
ColoredBox(
color: selected ? colors.accentSoft : Colors.transparent,
),
AnimatedPadding(
duration: NooMotion.fast,
curve: NooMotion.ease,
@@ -87,11 +92,21 @@ class NooPhotoTile extends StatelessWidget {
bottom: 6,
child: _VideoBadge(duration: videoDuration!),
),
if (selected || selectionMode)
if (selected ||
selectionMode ||
(onSelectionToggle != null &&
NooPointerSelection.available(context)))
Positioned(
left: 6,
top: 6,
child: _SelectCheck(selected: selected),
child:
NooPointerSelection.available(context) &&
onSelectionToggle != null
? NooPointerCheckbox(
selected: selected,
onToggle: onSelectionToggle,
)
: _SelectCheck(selected: selected),
),
],
),
@@ -152,9 +167,7 @@ class _SelectCheck extends StatelessWidget {
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? colors.accent : _videoBadgeColor,
border: selected
? null
: Border.all(color: Colors.white, width: 1.5),
border: selected ? null : Border.all(color: Colors.white, width: 1.5),
),
child: selected
? const Icon(LucideIcons.check, size: 14, color: Colors.white)
+28 -2
View File
@@ -22,6 +22,8 @@ import '../core/noo_progress_bar.dart';
class NooSidebar extends StatelessWidget {
final Widget? windowControls;
final Widget? account;
final Widget? search;
final bool groupItems;
final List<Widget> items;
final Widget? storage;
final Widget? settings;
@@ -34,6 +36,8 @@ class NooSidebar extends StatelessWidget {
super.key,
this.windowControls,
this.account,
this.search,
this.groupItems = false,
this.items = const [],
this.storage,
this.settings,
@@ -46,7 +50,10 @@ class NooSidebar extends StatelessWidget {
const padding = EdgeInsets.fromLTRB(12, 12, 12, 16);
return Container(
width: NooSizes.sidebarWidth,
width: (MediaQuery.sizeOf(context).width * .3).clamp(
200.0,
NooSizes.sidebarWidth,
),
decoration: BoxDecoration(
color: colors.bg,
border: showDivider
@@ -76,7 +83,26 @@ class NooSidebar extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 14),
child: account,
),
...items,
if (search != null)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: search,
),
if (groupItems)
Material(
color: colors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NooRadii.card),
side: BorderSide(color: colors.line),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: items,
),
)
else
...items,
const Spacer(),
?storage,
?settings,
+3 -1
View File
@@ -31,6 +31,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
/// Defaults to `surface` - the toolbar belongs to the main pane.
final Color? backgroundColor;
final bool framed;
const NooToolbar({
super.key,
@@ -42,6 +43,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
this.onBack,
this.onForward,
this.backgroundColor,
this.framed = true,
}) : assert(
title == null || titleWidget == null,
'Pass either title or titleWidget, not both.',
@@ -67,7 +69,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NooRadii.card),
side: BorderSide(color: colors.line),
side: framed ? BorderSide(color: colors.line) : BorderSide.none,
),
child: Container(
height: NooSizes.toolbar,
+23 -10
View File
@@ -1,16 +1,12 @@
import 'package:flutter/material.dart';
import 'nav/noo_nav_style.dart';
import '../../theme/design_tokens.dart';
/// The one place screens decide mobile vs desktop layout and iOS vs Android
/// chrome (DESIGN_SYSTEM.md 3), so every screen switches at the same point.
class NooLayout {
const NooLayout._();
/// Window width at which the shell swaps the bottom bar + drawer for the
/// desktop sidebar + toolbar, and screens switch to desktop recipes
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
static const double desktopBreakpoint = 900;
/// Shortest window side that counts as a tablet-class window (the usual
/// 600dp rule). A phone in landscape is wide but short, so it stays on the
/// mobile layout.
@@ -18,10 +14,13 @@ class NooLayout {
static bool isDesktop(BuildContext context) {
final size = MediaQuery.sizeOf(context);
return size.width >= desktopBreakpoint &&
size.shortestSide >= tabletShortestSide;
return size.shortestSide >= tabletShortestSide;
}
/// A quieter pane tone keeps cards distinct from their container.
static Color contentBackground(BuildContext context) =>
isDesktop(context) ? context.nooColors.surface2 : context.nooColors.bg;
static NooNavStyle navStyle(BuildContext context) =>
NooNavStyle.fromPlatform(Theme.of(context).platform);
@@ -33,9 +32,23 @@ class NooLayout {
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12;
/// Grid column count for [minTile]-wide tiles, never fewer than [phone]
/// (so phones keep their usual count while wide non-sidebar windows, e.g. a
/// tablet in portrait, get more instead of a few huge tiles).
/// File cards use the available pane width and a content-sized height.
static SliverGridDelegate fileGridDelegate(BuildContext context) {
final tablet = isDesktop(context);
final scale = MediaQuery.textScalerOf(context);
return SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 220,
mainAxisExtent:
(tablet ? 118 : 104) +
26 +
scale.scale(14) * 1.2 +
scale.scale(12) * 1.3,
crossAxisSpacing: tablet ? 16 : 10,
mainAxisSpacing: tablet ? 16 : 10,
);
}
/// Grid column count for [minTile]-wide tiles, never fewer than [phone].
static int gridColumns(
BuildContext context, {
required int phone,
+3 -3
View File
@@ -30,7 +30,7 @@ class ProfileAvatarButton extends StatelessWidget {
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Switched to ${target.username}'),
content: Text('Switched to ${target.label}'),
behavior: SnackBarBehavior.floating,
),
);
@@ -39,8 +39,8 @@ class ProfileAvatarButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<SessionController>();
final initial = provider.username.isNotEmpty
? provider.username[0].toUpperCase()
final initial = provider.displayName.isNotEmpty
? provider.displayName[0].toUpperCase()
: '?';
return Padding(
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../noo/lists/noo_settings_row.dart';
import 'settings_section.dart';
class SettingsAboutSection extends StatefulWidget {
const SettingsAboutSection({super.key});
@override
State<SettingsAboutSection> createState() => _SettingsAboutSectionState();
}
class _SettingsAboutSectionState extends State<SettingsAboutSection> {
late final Future<PackageInfo> _info = PackageInfo.fromPlatform();
Future<void> _openLink(String url) async {
try {
if (await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
)) {
return;
}
} catch (_) {}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open link. Please try again.')),
);
}
}
@override
Widget build(BuildContext context) => FutureBuilder<PackageInfo>(
future: _info,
builder: (context, snapshot) {
final info = snapshot.data;
final pending = snapshot.hasError ? 'Unavailable' : 'Loading…';
return SettingsSection(
title: 'About',
children: [
const NooSettingsRow(
label: Text('Noo'),
subtitle: Text('An independent Nextcloud client'),
),
NooSettingsRow(
label: const Text('Version'),
trailing: Text(info?.version ?? pending),
),
NooSettingsRow(
label: const Text('Build number'),
trailing: Text(info?.buildNumber ?? pending),
),
NooSettingsRow(
label: const Text('App identifier'),
subtitle: Text(info?.packageName ?? pending),
),
for (final link in {
'Website': 'https://noo.ayushya.dev',
'GitHub': 'https://github.com/ayushyamitabh/noo',
'Privacy policy': 'https://noo.ayushya.dev/privacy',
}.entries)
NooSettingsRow(
label: Text(link.key),
value: '',
onTap: () => _openLink(link.value),
),
],
);
},
);
}
+54 -34
View File
@@ -10,6 +10,7 @@ import '../noo/core/noo_chip.dart';
import '../noo/core/noo_progress_bar.dart';
import '../noo/noo_layout.dart';
import '../shell/shell_common.dart';
import '../shell/tablet_account_menu.dart';
import 'settings_dialogs.dart';
/// Settings section 1: the account card (DESIGN_SYSTEM.md 4 - "NooAvatar
@@ -18,7 +19,9 @@ import 'settings_dialogs.dart';
/// the pre-rework card and have no other home, so they stay here as a
/// button row under the storage meter.
class SettingsAccountCard extends StatelessWidget {
const SettingsAccountCard({super.key});
final bool compact;
final VoidCallback? onManage;
const SettingsAccountCard({super.key, this.compact = false, this.onManage});
Future<void> _refresh(BuildContext context, FilesController files) async {
await files.refreshData();
@@ -48,7 +51,7 @@ class SettingsAccountCard extends StatelessWidget {
context,
session,
accountId: id,
username: session.username,
username: session.displayName,
host: serverHost(session.serverUrl),
isActive: true,
);
@@ -60,52 +63,67 @@ class SettingsAccountCard extends StatelessWidget {
final session = context.watch<SessionController>();
final files = context.watch<FilesController>();
final quota = files.quota;
final name = quota?.userName ?? session.username;
final name = session.displayName;
return Container(
padding: const EdgeInsets.all(NooSpace.lg),
padding: EdgeInsets.all(compact ? 10 : NooSpace.lg),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.card),
border: NooLayout.isDesktop(context) ? Border.all(color: colors.line) : null,
border: NooLayout.isDesktop(context)
? Border.all(color: colors.line)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
NooAvatar(initials: accountInitial(name), current: true, size: 56),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.cardTitle.copyWith(color: colors.fg1),
),
const SizedBox(height: 2),
Text(
serverHost(session.serverUrl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
if (compact)
TabletAccountMenu(framed: false, onManage: onManage)
else
Row(
children: [
NooAvatar(
initials: accountInitial(name),
current: true,
size: 56,
),
),
],
),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.cardTitle.copyWith(color: colors.fg1),
),
const SizedBox(height: 2),
Text(
serverHost(session.serverUrl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
),
),
],
),
const SizedBox(height: NooSpace.lg),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Storage', style: NooText.label.copyWith(color: colors.fg2)),
Text(
quota != null ? quotaDetail(quota) : 'Loading…',
style: NooText.meta.copyWith(color: colors.fg3),
const SizedBox(width: 6),
Expanded(
child: Text(
quota != null ? quotaDetail(quota) : 'Loading…',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: NooText.meta.copyWith(color: colors.fg3),
),
),
],
),
@@ -116,7 +134,9 @@ class SettingsAccountCard extends StatelessWidget {
Wrap(
spacing: 8,
runSpacing: 8,
children: [for (final group in quota.groups) NooChip(child: Text(group))],
children: [
for (final group in quota.groups) NooChip(child: Text(group)),
],
),
],
const SizedBox(height: NooSpace.md),
+28 -7
View File
@@ -34,7 +34,7 @@ class SettingsAccountsSection extends StatelessWidget {
context,
session,
accountId: account.id,
username: account.username,
username: account.label,
host: serverHost(account.serverUrl),
isActive: account.id == session.activeAccountId,
),
@@ -45,7 +45,9 @@ class SettingsAccountsSection extends StatelessWidget {
label: const Text('Add account'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const LoginView(isAddingAccount: true)),
MaterialPageRoute(
builder: (_) => const LoginView(isAddingAccount: true),
),
),
),
],
@@ -79,11 +81,18 @@ class _SavedAccountRow extends StatelessWidget {
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: 10,
),
child: Row(
spacing: 12,
children: [
NooAvatar(initials: accountInitial(account.username), current: isActive, size: 36),
NooAvatar(
initials: accountInitial(account.label),
current: isActive,
size: 36,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -108,15 +117,27 @@ class _SavedAccountRow extends StatelessWidget {
],
),
),
if (isActive) Icon(LucideIcons.checkCircle, size: 20, color: colors.accentText),
if (isActive)
Icon(
LucideIcons.checkCircle,
size: 20,
color: colors.accentText,
),
GestureDetector(
onTap: onRemove,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
child: Icon(LucideIcons.trash2, size: 16, color: colors.danger),
decoration: BoxDecoration(
color: colors.surface2,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.trash2,
size: 16,
color: colors.danger,
),
),
),
],
@@ -9,6 +9,7 @@ import '../noo/lists/noo_settings_row.dart';
import '../noo/lists/noo_grouped_list.dart';
import '../noo/core/noo_segmented_control.dart';
import '../noo/noo_layout.dart';
import 'settings_section.dart';
/// Settings section: the priority order bulk actions (favorite, share,
/// download, ...) show in on the Files/Photos/Favorites multi-select
@@ -77,10 +78,11 @@ class SettingsActionBarSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Action bar',
style: NooText.cardTitle.copyWith(color: colors.fg1),
),
if (SettingsCategoryHeading.shows(context, 'Action bar'))
Text(
'Action bar',
style: NooText.cardTitle.copyWith(color: colors.fg1),
),
const SizedBox(height: 4),
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
const SizedBox(height: NooSpace.md),
+28 -25
View File
@@ -43,22 +43,23 @@ class SettingsAppearanceSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsController>();
final tablet = NooLayout.isDesktop(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSection(
title: 'Appearance',
notice:
'Bottom bar style, menu style and search in bottom bar have no effect on wide tablets, which use a sidebar.',
children: [
_ThemeRow(settings: settings),
_BottomBarStyleRow(settings: settings),
if (!tablet) _BottomBarStyleRow(settings: settings),
NooSettingsRow(
icon: LucideIcons.glassWater,
label: const Text('Frosted glass'),
subtitle: const Text(
'Blurred bottom navigation, avatar menus and media viewer panels',
subtitle: Text(
tablet
? 'Blurred media viewer panels'
: 'Blurred bottom navigation, avatar menus and media viewer panels',
),
trailing: NooToggle(
checked: settings.bottomBarFrosted,
@@ -66,8 +67,8 @@ class SettingsAppearanceSection extends StatelessWidget {
),
),
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
_NavMenuStyleRow(settings: settings),
if (settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
if (!tablet) _NavMenuStyleRow(settings: settings),
if (!tablet && settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
NooSettingsRow(
icon: LucideIcons.userRound,
label: const Text('Avatar position'),
@@ -84,27 +85,29 @@ class SettingsAppearanceSection extends StatelessWidget {
],
),
),
NooSettingsRow(
icon: LucideIcons.search,
label: const Text('Search in bottom bar'),
subtitle: const Text(
'Adds Search to the bottom bar and removes it from the top '
'bar - leaves room for one fewer regular tab',
if (!tablet)
NooSettingsRow(
icon: LucideIcons.search,
label: const Text('Search in bottom bar'),
subtitle: const Text(
'Adds Search to the bottom bar and removes it from the top '
'bar - leaves room for one fewer regular tab',
),
trailing: NooToggle(
checked: settings.searchInBottomBar,
onChanged: settings.setSearchInBottomBar,
),
),
trailing: NooToggle(
checked: settings.searchInBottomBar,
onChanged: settings.setSearchInBottomBar,
),
),
],
),
const SizedBox(height: NooSpace.xl),
SettingsSection(
title: 'Upload button',
notice:
'Auto shows the label on Files and Photos and shrinks to an icon on other tabs. Mini and Expanded stay the same on every tab. Has no effect on wide tablets.',
children: [_FabStyleRow(settings: settings)],
),
if (!tablet) const SizedBox(height: NooSpace.xl),
if (!tablet)
SettingsSection(
title: 'Upload button',
notice:
'Auto shows the label on Files and Photos and shrinks to an icon on other tabs. Mini and Expanded stay the same on every tab.',
children: [_FabStyleRow(settings: settings)],
),
const SizedBox(height: NooSpace.xl),
SettingsSection(
title: 'Advanced appearance',
+16 -3
View File
@@ -22,8 +22,8 @@ Future<void> confirmRemoveAccount(
}) async {
final hasOtherAccounts = session.accounts.length > 1;
final message = isActive && hasOtherAccounts
? 'Remove $username ($host)? Another saved account will become active.'
: 'Remove $username ($host)? You can add it again later.';
? 'Remove $username ($host)? Its synced files will be removed from this device. Another saved account will become active.'
: 'Remove $username ($host)? Its synced files will be removed from this device. You can add it again later.';
var confirmed = false;
void cancel() => Navigator.pop(context);
@@ -80,7 +80,20 @@ Future<void> confirmRemoveAccount(
if (!confirmed) return;
if (!context.mounted) return;
final navigator = Navigator.of(context);
await session.removeAccount(accountId);
try {
await session.removeAccount(accountId);
} catch (error) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Could not remove local account data. Please try again.',
),
),
);
}
return;
}
// Only when this was the last saved account does isLoggedIn drop to false
// and main.dart swap the root route to LoginView underneath - pop back to
// it then, rather than leaving Settings stranded on top. Removing a
+21 -2
View File
@@ -6,6 +6,24 @@ import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart';
/// Category heading already supplied by the tablet toolbar.
class SettingsCategoryHeading extends InheritedWidget {
final String title;
const SettingsCategoryHeading({
super.key,
required this.title,
required super.child,
});
static bool shows(BuildContext context, String title) =>
context
.dependOnInheritedWidgetOfExactType<SettingsCategoryHeading>()
?.title !=
title;
@override
bool updateShouldNotify(SettingsCategoryHeading oldWidget) =>
title != oldWidget.title;
}
/// One block of Settings (DESIGN_SYSTEM.md 4's 9-part order): a
/// [NooGroupedList] on mobile (label above a radius-20 card), or a titled,
/// bordered radius-20 card holding a flat row group on desktop
@@ -34,7 +52,7 @@ class SettingsSection extends StatelessWidget {
final colors = context.nooColors;
if (!NooLayout.isDesktop(context)) {
return NooGroupedList(
label: title,
label: SettingsCategoryHeading.shows(context, title) ? title : null,
notice: notice != null ? NooInfoNote(message: notice!) : null,
footer: subtitle != null ? Text(subtitle!) : null,
children: children,
@@ -50,7 +68,8 @@ class SettingsSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: NooText.cardTitle.copyWith(color: colors.fg1)),
if (SettingsCategoryHeading.shows(context, title))
Text(title, style: NooText.cardTitle.copyWith(color: colors.fg1)),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)),
-1
View File
@@ -47,7 +47,6 @@ class SettingsSwipeSection extends StatelessWidget {
return SettingsSection(
title: 'Swipe on a file',
subtitle: 'Choose what swiping a file left or right does in list view',
notice: 'Swipe actions have no effect on wide tablets.',
children: [
NooSettingsRow(
icon: LucideIcons.chevronsRight,
+13 -11
View File
@@ -78,17 +78,18 @@ class SettingsTabsSection extends StatelessWidget {
final desktop = NooLayout.isDesktop(context);
final optionRows = [
NooSettingsRow(
icon: LucideIcons.arrowUpDown,
label: const Text('Tap tab to scroll to top'),
subtitle: const Text(
'Tapping the current bottom bar tab scrolls its list back to the top',
if (!desktop)
NooSettingsRow(
icon: LucideIcons.arrowUpDown,
label: const Text('Tap tab to scroll to top'),
subtitle: const Text(
'Tapping the current bottom bar tab scrolls its list back to the top',
),
trailing: NooToggle(
checked: settings.tapTabToScrollTop,
onChanged: settings.setTapTabToScrollTop,
),
),
trailing: NooToggle(
checked: settings.tapTabToScrollTop,
onChanged: settings.setTapTabToScrollTop,
),
),
NooSettingsRow(
icon: LucideIcons.star,
label: const Text('Default tab'),
@@ -136,7 +137,8 @@ class SettingsTabsSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Tabs', style: NooText.cardTitle.copyWith(color: colors.fg1)),
if (SettingsCategoryHeading.shows(context, 'Tabs'))
Text('Tabs', style: NooText.cardTitle.copyWith(color: colors.fg1)),
const SizedBox(height: 4),
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
const SizedBox(height: NooSpace.md),
+2 -2
View File
@@ -510,10 +510,10 @@ class _ShareSheetState extends State<ShareSheet> {
const SizedBox(height: NooSpace.xs),
NooPersonAccessRow(
avatar: NooAvatar(
initials: _initial(session.username),
initials: _initial(session.displayName),
current: true,
),
name: session.username,
name: session.displayName,
owner: true,
),
for (final share in peopleShares)
+2 -2
View File
@@ -88,7 +88,7 @@ void showAccountSwitcher(BuildContext context) {
children: [
for (final account in session.accounts)
_AccountSwitchRow(
name: account.username,
name: account.label,
host: serverHost(account.serverUrl),
active: account.id == session.activeAccountId,
onTap: account.id == session.activeAccountId
@@ -278,7 +278,7 @@ class ShellAvatarButton extends StatelessWidget {
radius: hitBox / 2,
child: Center(
child: NooAvatar(
initials: accountInitial(session.username),
initials: accountInitial(session.displayName),
current: true,
size: 32,
),
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart';
import '../../views/login_view.dart';
import '../noo/core/noo_avatar.dart';
import '../noo/core/noo_button.dart';
import 'shell_common.dart';
class TabletAccountMenu extends StatefulWidget {
final bool framed;
final VoidCallback? onManage;
const TabletAccountMenu({super.key, this.framed = true, this.onManage});
@override
State<TabletAccountMenu> createState() => _TabletAccountMenuState();
}
class _TabletAccountMenuState extends State<TabletAccountMenu> {
bool expanded = false;
@override
Widget build(BuildContext context) {
final session = context.watch<SessionController>();
final colors = context.nooColors;
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 340);
Widget accountRow(
String name, {
bool current = false,
VoidCallback? onTap,
}) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(NooRadii.input),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
child: Row(
children: [
NooAvatar(
initials: accountInitial(name),
current: current,
size: 32,
),
const SizedBox(width: 8),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(fontSize: 14, color: colors.fg1),
),
),
if (current)
AnimatedRotation(
turns: expanded ? .5 : 0,
duration: duration,
curve: Curves.easeInOutCubicEmphasized,
child: Icon(
LucideIcons.chevronDown,
size: 18,
color: colors.accentText,
),
),
],
),
),
);
return Material(
color: widget.framed ? colors.surface : Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NooRadii.card),
side: widget.framed ? BorderSide(color: colors.line) : BorderSide.none,
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
accountRow(
session.displayName,
current: true,
onTap: () => setState(() => expanded = !expanded),
),
AnimatedSize(
duration: duration,
curve: Curves.easeInOutCubicEmphasized,
alignment: Alignment.topCenter,
child: expanded
? Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final account in session.accounts)
if (account.id != session.activeAccountId)
accountRow(
account.label,
onTap: () async {
await session.switchAccount(account.id);
if (mounted) setState(() => expanded = false);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 10),
child: Column(
spacing: 6,
children: [
NooButton(
size: NooButtonSize.compact,
fullWidth: true,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
const LoginView(isAddingAccount: true),
),
),
child: const Text('Add Account'),
),
NooButton(
size: NooButtonSize.compact,
fullWidth: true,
variant: NooButtonVariant.secondary,
onTap:
widget.onManage ??
() => openSettings(context),
child: const Text('Manage Accounts'),
),
],
),
),
],
)
: const SizedBox(width: double.infinity),
),
],
),
);
}
}
+5 -14
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
import 'noo/noo_layout.dart';
/// Wraps [child] as a sliver that pins to the top of the scroll view when
/// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away
@@ -30,20 +30,11 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
double shrinkOffset,
bool overlapsContent,
) {
// `context.nooColors.bg`, not `Theme.of(context).colorScheme.surface` -
// the latter is Flutter's own Material 3 scheme, seeded from the user's
// accent color choice (see AppTheme.light/dark), so it carried a faint
// hue of whatever accent is picked, and didn't match the plain
// `colors.bg` every one of these screens' own `ColoredBox` background
// uses. `bg`, not `surface`, so the sort/filter chips and List/Grid
// toggle riding on top of this (each already `colors.surface`-filled)
// still pop against it, the same as they do everywhere else in the app
// - matching `surface` here would make this pinned header the one place
// they'd flatten into their background instead. `surfaceTintColor:
// Colors.transparent` guards against the same M3 elevation-tint
// behavior even if this ever gets a non-zero elevation.
// Match the pane so pinned controls preserve its rounded silhouette.
return Material(
color: floating ? Colors.transparent : context.nooColors.bg,
color: floating
? Colors.transparent
: NooLayout.contentBackground(context),
surfaceTintColor: Colors.transparent,
child: child,
);
+1 -1
View File
@@ -17,7 +17,7 @@ class MediaPdfPreview extends StatefulWidget {
/// `FileViewerScreen.localPathResolver`'s doc comment.
final Future<String?> Function(NextcloudItem item)? localPathResolver;
/// Space reserved above the page for an overlaid top bar (0 when hidden).
/// Stable space reserved above the page for the overlaid top bar.
final double topInset;
const MediaPdfPreview({
+48
View File
@@ -648,6 +648,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
url: "https://pub.dev"
source: hosted
version: "10.2.2"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
version: "4.1.0"
path:
dependency: "direct main"
description:
@@ -965,6 +981,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
url: "https://pub.dev"
source: hosted
version: "6.3.3"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
url: "https://pub.dev"
source: hosted
version: "6.3.33"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
url: "https://pub.dev"
source: hosted
version: "6.4.2"
url_launcher_linux:
dependency: transitive
description:
@@ -973,6 +1013,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
url: "https://pub.dev"
source: hosted
version: "3.2.6"
url_launcher_platform_interface:
dependency: transitive
description:
+3 -1
View File
@@ -14,7 +14,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.2+5
version: 1.0.3+6
environment:
sdk: ^3.12.2
@@ -57,6 +57,8 @@ dependencies:
permission_handler: ^11.3.1
connectivity_plus: ^6.1.5
flutter_markdown_plus: ^1.0.12
package_info_plus: ^10.2.2
url_launcher: ^6.3.3
dev_dependencies:
flutter_test:
@@ -117,4 +117,67 @@ void main() {
connectivity.dispose();
session.dispose();
});
for (final fails in [false, true]) {
test('account removal awaits native cleanup (failure: $fails)', () async {
SharedPreferences.setMockInitialValues({
'account_migration_v1_done': true,
'accounts_list': jsonEncode([
const SavedAccount(
id: accountId,
serverUrl: 'https://server.example.com',
username: 'alice',
).toJson(),
]),
'active_account_id': accountId,
'acct_${accountId}_ui_synced_folders': ['/Photos'],
});
final connectivity = ConnectivityController();
await pumpUntil(() => connectivity.isOffline);
final session = SessionController(connectivity);
await pumpUntil(() => session.isLoggedIn);
const channel = MethodChannel('dev.ayushya.noo/sync_service');
var cleanupCalled = false;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'removeAccountData') {
expect(call.arguments['accountId'], accountId);
expect(session.accounts, isNotEmpty);
cleanupCalled = true;
if (fails) throw PlatformException(code: 'cleanup_failed');
}
return null;
});
try {
if (fails) {
await expectLater(
session.removeAccount(accountId),
throwsA(isA<PlatformException>()),
);
expect(session.accounts, isNotEmpty);
expect(
(await SharedPreferences.getInstance()).getStringList(
'acct_${accountId}_ui_synced_folders',
),
['/Photos'],
);
} else {
await session.removeAccount(accountId);
expect(session.accounts, isEmpty);
expect(session.isLoggedIn, false);
expect(
(await SharedPreferences.getInstance()).containsKey(
'acct_${accountId}_ui_synced_folders',
),
false,
);
}
expect(cleanupCalled, true);
} finally {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
session.dispose();
connectivity.dispose();
}
});
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:noo/models/saved_account.dart';
void main() {
test('display name survives persistence without changing login identity', () {
const account = SavedAccount(
id: 'id',
serverUrl: 'https://cloud.example',
username: 'alice123',
displayName: ' Alice Smith ',
);
final restored = SavedAccount.fromJson(account.toJson());
expect(restored.label, 'Alice Smith');
expect(restored.username, 'alice123');
expect(restored.id, 'id');
});
test('legacy accounts and blank names fall back to username', () {
final legacy = SavedAccount.fromJson({
'id': 'id',
'serverUrl': 'https://cloud.example',
'username': 'alice123',
});
expect(legacy.label, 'alice123');
expect(
const SavedAccount(
id: 'id',
serverUrl: '',
username: 'alice123',
displayName: ' ',
).label,
'alice123',
);
});
}
+107 -4
View File
@@ -1,5 +1,10 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:noo/widgets/settings/settings_about.dart';
import 'package:noo/widgets/noo/core/noo_search_field.dart';
import 'package:noo/views/search_view.dart';
import 'package:noo/widgets/shell/tablet_account_menu.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
@@ -80,9 +85,15 @@ void main() {
});
});
Future<void> pumpSettings(WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
Future<void> pumpSettings(
WidgetTester tester, {
Widget home = const AccountView(),
Size size = const Size(400, 800),
}) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = size;
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(
MultiProvider(
@@ -102,7 +113,7 @@ void main() {
],
child: MaterialApp(
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
home: const AccountView(),
home: home,
),
),
);
@@ -138,6 +149,7 @@ void main() {
'Tabs',
'Action bar',
'Swipe on a file',
'About',
];
testWidgets(
@@ -195,4 +207,95 @@ void main() {
expect(find.text('alice'), findsOneWidget);
},
);
testWidgets(
'tablet hides phone navigation preferences and keeps viewer glass',
(tester) async {
await pumpSettings(tester, size: const Size(768, 1024));
expect(find.text('Swipe on a file'), findsNothing);
await tester.tap(find.text('Appearance'));
await settleNav(tester);
for (final label in [
'Bottom bar style',
'Menu style',
'Avatar position',
'Search in bottom bar',
'Upload button',
]) {
expect(find.text(label), findsNothing);
}
expect(find.text('Frosted glass'), findsOneWidget);
expect(find.text('Theme'), findsOneWidget);
await tester.tap(find.text('Tabs'));
await settleNav(tester);
expect(find.text('Tap tab to scroll to top'), findsNothing);
expect(find.text('Default tab'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets('About reads installed version and build', (tester) async {
PackageInfo.setMockInitialValues(
appName: 'Noo',
packageName: 'dev.ayushya.noo',
version: '2.3.4',
buildNumber: '42',
buildSignature: '',
);
await pumpSettings(
tester,
home: const Scaffold(body: SettingsAboutSection()),
);
expect(find.text('2.3.4'), findsOneWidget);
expect(find.text('42'), findsOneWidget);
expect(find.text('dev.ayushya.noo'), findsOneWidget);
});
testWidgets('tablet Search header stays below status bar', (tester) async {
tester.view.padding = const FakeViewPadding(top: 48);
addTearDown(tester.view.resetPadding);
await pumpSettings(
tester,
home: const SearchView(),
size: const Size(768, 1024),
);
expect(
tester.getTopLeft(find.byType(NooSearchField)).dy,
greaterThanOrEqualTo(48),
);
expect(tester.takeException(), isNull);
});
testWidgets(
'portrait tablet Settings selects sections inline and expands accounts',
(tester) async {
await pumpSettings(tester, size: const Size(768, 1024));
await tester.tap(find.text('Security'));
await settleNav(tester);
expect(
find.text("Require this device's PIN or biometric to open Noo"),
findsOneWidget,
);
// One sidebar label and one toolbar title; no repeated card heading.
expect(find.text('Security'), findsNWidgets(2));
expect(find.byType(NooTopBarBack), findsNothing);
final account = find.descendant(
of: find.byType(TabletAccountMenu),
matching: find.text('alice'),
);
final menuHeight = tester.getSize(find.byType(TabletAccountMenu)).height;
await tester.tap(account);
await settleNav(tester);
expect(
tester.getSize(find.byType(TabletAccountMenu)).height,
greaterThan(menuHeight),
);
expect(find.text('Manage Accounts'), findsOneWidget);
await tester.tap(find.text('Manage Accounts'));
await settleNav(tester);
expect(
find.text("Require this device's PIN or biometric to open Noo"),
findsNothing,
);
expect(tester.takeException(), isNull);
},
);
}
+15
View File
@@ -11,6 +11,7 @@ import 'package:noo/services/nextcloud_service.dart';
import 'package:noo/views/file_viewer_screen.dart';
import 'package:noo/widgets/noo/core/noo_button.dart';
import 'package:noo/widgets/viewer/media_action_bar.dart';
import 'package:noo/widgets/viewer/media_pdf_preview.dart';
import 'package:provider/provider.dart';
import '../widgets/noo/noo_test_utils.dart';
@@ -94,6 +95,20 @@ void main() {
return service;
}
testWidgets('PDF viewport remains fixed when toolbar hides', (tester) async {
await mount(tester, const Size(390, 800), name: 'document.pdf');
final before = tester
.widget<MediaPdfPreview>(find.byType(MediaPdfPreview))
.topInset;
await tester.tapAt(const Offset(195, 350));
await tester.pump(const Duration(milliseconds: 400));
expect(
tester.widget<MediaPdfPreview>(find.byType(MediaPdfPreview)).topInset,
before,
);
expect(before, greaterThan(0));
});
for (final width in [390.0, 1200.0]) {
testWidgets('text actions use the top bar at width $width', (tester) async {
final service = await mount(tester, Size(width, 800));
+138
View File
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:noo/widgets/noo/lists/noo_selection_bar.dart';
import 'package:noo/models/selection_action.dart';
import 'package:noo/widgets/noo/noo_layout.dart';
import 'package:noo/widgets/noo/media/noo_grid_card.dart';
import 'package:noo/widgets/noo/files/noo_file_table.dart';
import 'package:noo/widgets/noo/files/noo_file_kind.dart';
import 'noo_test_utils.dart';
import 'package:noo/widgets/noo/media/noo_photo_group.dart';
void main() {
setUpNooTests();
testWidgets('photo columns follow the pane rather than the tablet width', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Center(
child: SizedBox(
width: 400,
height: 400,
child: CustomScrollView(
slivers: [
NooPhotoGrid(
minTileWidth: 150,
itemCount: 6,
itemBuilder: (_, index) =>
SizedBox(key: ValueKey('photo-$index')),
),
],
),
),
),
),
);
final first = tester.getRect(find.byKey(const ValueKey('photo-0')));
final third = tester.getRect(find.byKey(const ValueKey('photo-2')));
final fourth = tester.getRect(find.byKey(const ValueKey('photo-3')));
expect(first.width, greaterThan(100));
expect(third.right, lessThanOrEqualTo(first.left + 400));
expect(fourth.top, greaterThan(first.top));
expect(tester.takeException(), isNull);
});
for (final width in [320.0, 430.0, 700.0]) {
testWidgets('selection actions fit portrait tablet pane $width', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: nooTheme(Brightness.light),
builder: (_, child) => Material(child: child),
home: Center(
child: SizedBox(
width: width,
child: NooSelectionBar(
count: 100,
isDesktop: true,
onClose: () {},
actions: [
for (final kind in SelectionActionKind.values)
SelectionAction(
kind: kind,
icon: kind.icon,
label: kind.label,
onTap: () {},
),
],
),
),
),
),
);
expect(tester.takeException(), isNull);
expect(find.text('100 selected'), findsOneWidget);
});
testWidgets('file cards fit pane width $width', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: nooTheme(Brightness.light),
builder: (_, child) => Material(child: child),
home: MediaQuery(
data: const MediaQueryData(
size: Size(768, 1024),
textScaler: TextScaler.linear(1.4),
),
child: Builder(
builder: (context) => Center(
child: SizedBox(
width: width,
height: 500,
child: GridView.builder(
gridDelegate: NooLayout.fileGridDelegate(context),
itemCount: 6,
itemBuilder: (_, index) => const NooGridCard(
name: 'Long file name.pdf',
meta: '2 MB',
thumbnailHeight: 118,
),
),
),
),
),
),
),
);
expect(tester.takeException(), isNull);
expect(
tester.getSize(find.byType(NooGridCard).first).height,
lessThan(200),
);
});
testWidgets('file table preserves name space at $width', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: nooTheme(Brightness.light),
builder: (_, child) => Material(child: child),
home: Center(
child: SizedBox(
width: width,
child: const NooFileTableRow(
kind: NooFileKind.folder,
name: 'Important project documents',
col2: 'Yesterday',
col3: 'Folder',
),
),
),
),
);
expect(tester.takeException(), isNull);
expect(
tester.getSize(find.text('Important project documents')).width,
greaterThan(80),
);
});
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:noo/widgets/noo/noo_layout.dart';
void main() {
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
for (final size in [
const Size(600, 960),
const Size(768, 1024),
const Size(1024, 768),
const Size(430, 932),
const Size(932, 430),
const Size(599, 1024),
]) {
testWidgets('$platform sidebar at $size', (tester) async {
bool? sidebar;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: platform),
home: MediaQuery(
data: MediaQueryData(size: size),
child: Builder(
builder: (context) {
sidebar = NooLayout.isDesktop(context);
return const SizedBox();
},
),
),
),
);
expect(sidebar, size.shortestSide >= 600);
});
}
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:noo/widgets/noo/core/noo_pointer_selection.dart';
void main() {
testWidgets('mouse reveals checkbox; clicking selects without opening item', (
tester,
) async {
var opened = 0;
var toggled = 0;
await tester.pumpWidget(
MaterialApp(
home: NooPointerSelection(
child: Scaffold(
body: GestureDetector(
onTap: () => opened++,
child: NooPointerCheckbox(
selected: false,
onToggle: () => toggled++,
),
),
),
),
),
);
expect(find.byType(Checkbox), findsNothing);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: const Offset(10, 10));
await tester.pumpAndSettle();
expect(find.byType(Checkbox), findsOneWidget);
await tester.tap(find.byType(Checkbox));
expect(toggled, 1);
expect(opened, 0);
await mouse.removePointer();
await tester.pumpAndSettle();
expect(find.byType(Checkbox), findsNothing);
});
testWidgets('nonselectable items never show checkboxes', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: NooPointerSelection(child: NooPointerCheckbox(selected: false)),
),
);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: const Offset(10, 10));
await tester.pumpAndSettle();
expect(find.byType(Checkbox), findsNothing);
await mouse.removePointer();
await tester.pumpAndSettle();
});
}