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

This commit is contained in:
Ayushya Amitabh
2026-10-09 00:36:42 -04:00
parent 79085874a1
commit 0a4608e6c3
67 changed files with 1740 additions and 451 deletions
@@ -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();
});
}