Set app version to 1.0.0+3; share sheet and markdown viewer polish; remove demo screenshot tooling
Build App Bundle / build (push) Successful in 5m22s

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 14:50:07 -04:00
co-authored by Claude Code
parent 8aa7015c3f
commit 9c9e57ec22
30 changed files with 341 additions and 1230 deletions
+2
View File
@@ -801,3 +801,5 @@ Flutter's own default to already be high enough.
`isRestoringSession` still gates the splash screen until the above resolves
— see `standards.md` for why widget tests must mock both storage channels
rather than relying on this async path throwing naturally.
- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`).
-26
View File
@@ -90,32 +90,6 @@ class/method already makes obvious.
`Align`).
- Run with `flutter test`.
## Play Store screenshots
`bash tool/screenshots.sh` (Git Bash is fine on Windows) regenerates the store
listing screenshots **without any real data**: it runs the real, unmodified app
against a throwaway Docker Nextcloud (`tool/demo_server/`) seeded with invented
content, on a wiped 1080x1920 emulator with a demo-mode status bar, and drives
it with `integration_test/store_screenshots_test.dart` (host-side driver:
`test_driver/integration_test.dart`, which saves `adb screencap` images).
Output lands in `store_listing/screenshots/` (gitignored) after
`tool/finalize_screenshots.py` flattens alpha and checks Play's size/aspect
limits (each side 320-3840 px, long side at most 2x the short side - a stock
1080x2400 phone screen is 2.22:1 and would be rejected).
- The test logs in by writing the demo user's account/app password into
storage via `AccountStore` before calling `main()`, so it skips the browser
login flow; it also fixes the theme (light, non-dynamic colour), the visible
tabs and the first-run notification prompt so shots are reproducible.
- It finds things by tab icon (`AppTab.icon` inside `FloatingBottomNavBar`),
tooltips, the `ValueKey('files')`/`ValueKey('offline')` on the two
`FilesView`s, and the fake content's names (defined in
`tool/demo_server/seed.py`) - keep those in sync if you rename either side.
- `flutter test` only runs `test/`, so this never runs as part of the normal
suite; `integration_test` is a dev-only dependency.
- Always review the images before uploading - see
`tool/demo_server/README.md` for the known places real data could appear.
## Local install/deploy
Never use `flutter install` to push a build to a test device — it always
+16 -4
View File
@@ -30,10 +30,12 @@ widgets. Key points:
OS-provided `ColorScheme` wins over the seed color — always thread both
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
- **Font**: Inter via `google_fonts`, applied through
`GoogleFonts.interTextTheme(...)`. In tests, set
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without
it, the font-fetch call to Google's CDN can stall `pumpAndSettle`
indefinitely (see `standards.md`).
`GoogleFonts.interTextTheme(...)`. Font files ship in
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
`main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
app never contacts Google (the privacy policy on the website relies on
this — keep it true; add new weights as bundled files). Tests set the
same flag in `setUpAll` (see `standards.md`).
- **Cards**: flat (`elevation: 0`), 20px rounded corners,
`surfaceContainerLow`.
- **App bars**: flat, not centered, `surface` background.
@@ -324,3 +326,13 @@ blocks are noted where they matter:
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
layer), so it renders at a sensible size at 72-80px.
## Text/markdown viewer
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
-3
View File
@@ -45,6 +45,3 @@ app.*.map.json
/android/app/profile
/android/app/release
# Play Store screenshot tooling (tool/screenshots.sh)
/store_listing/screenshots/
/tool/demo_server/.venv/
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,325 +0,0 @@
// Drives the real app through its tabs and states against the fake-data demo
// server and captures the Play Store screenshots. Not part of the normal test
// suite (`flutter test` only runs `test/`) - run it through
// `tool/screenshots.sh`, which starts the demo server, an emulator and the
// driver in `test_driver/integration_test.dart` that saves the images.
//
// The app starts already logged in: the demo user's credentials are written
// straight into storage before `main()` runs, so Login Flow v2's browser
// step is skipped.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:noo/main.dart' as app;
import 'package:noo/models/app_tab.dart';
import 'package:noo/models/saved_account.dart';
import 'package:noo/providers/files_controller.dart';
import 'package:noo/providers/settings_controller.dart';
import 'package:noo/providers/sync_status_controller.dart';
import 'package:noo/services/account_store.dart';
import 'package:noo/theme/design_tokens.dart';
import 'package:noo/widgets/bottom_nav_bar.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _serverUrl = String.fromEnvironment(
'DEMO_SERVER_URL',
defaultValue: 'http://localhost',
);
const _username = String.fromEnvironment('DEMO_USERNAME', defaultValue: 'Alex');
const _appPassword = String.fromEnvironment('DEMO_APP_PASSWORD');
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'Play Store screenshots',
(tester) async {
expect(
_appPassword,
isNotEmpty,
reason: 'Pass the demo user\'s app password via '
'--dart-define=DEMO_APP_PASSWORD=... (tool/screenshots.sh does).',
);
await _seedLoggedInStorage();
app.main();
await tester.pump();
await _pumpUntil(tester, find.byType(app.MainShellView));
// Android only: required before `takeScreenshot()` can capture
// anything at all.
if (Platform.isAndroid) await binding.convertFlutterSurfaceToImage();
Future<void> shoot(String name) async {
// Let scroll/transition animations and image fades finish first.
await tester.pump(const Duration(seconds: 1));
await binding.takeScreenshot(name);
}
// ---- 01 Files (list) ------------------------------------------------
final filesTab = find.byKey(const ValueKey('files'));
await _pumpUntil(
tester,
find.descendant(of: filesTab, matching: find.text('Documents')),
);
await shoot('01_Files');
// ---- 02 Files (grid, inside a folder of photos) --------------------
final files = _read<FilesController>(tester);
await files.navigateToAbsoluteFolder('/Trips');
await _pumpUntil(
tester,
find.descendant(of: filesTab, matching: find.text('Lake sunrise.jpg')),
);
// The controls row scrolls horizontally and doesn't fit every control
// on a narrow screen, so "Grid view" can start off past the clipped
// edge - not just off the *current* tab (which `.hitTestable()` alone
// would handle), but off the *visible part of this tab's own row*.
// `_tapInTab` scrolls it into view first.
await _tapInTab(tester, filesTab, find.byTooltip('Grid view'));
await tester.pump(const Duration(seconds: 4)); // thumbnails
await shoot('02_Files_Grid');
// ---- 07 File viewer -------------------------------------------------
await _tapInTab(tester, filesTab, find.text('Lake sunrise.jpg'));
await tester.pump(const Duration(seconds: 4));
await shoot('07_Viewer');
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
await tester.pump(const Duration(seconds: 1));
// Back to the root folder, list view.
await _tapInTab(tester, filesTab, find.byTooltip('List view'));
await files.navigateToAbsoluteFolder('/');
await tester.pump(const Duration(seconds: 2));
// ---- 03 Photos ------------------------------------------------------
await _goToTab(tester, AppTab.photos);
await tester.pump(const Duration(seconds: 5)); // grid + thumbnails
await shoot('03_Photos');
// ---- 04 Favorites ---------------------------------------------------
await _goToTab(tester, AppTab.favorites);
await _pumpUntil(tester, find.text('Project brief.pdf'));
await shoot('04_Favorites');
// ---- 05 Activity ----------------------------------------------------
await _goToTab(tester, AppTab.activity);
await tester.pump(const Duration(seconds: 3));
await shoot('05_Activity');
// ---- 06 Shares ------------------------------------------------------
await _goToTab(tester, AppTab.shares);
await tester.pump(const Duration(seconds: 3));
await shoot('06_Shares');
// ---- 08 Sync status panel ------------------------------------------
// Sync two folders to the device; the header chip flips to "Synced"
// once the native worker has finished, and tapping it opens the panel.
await _goToTab(tester, AppTab.files);
await _read<SyncStatusController>(
tester,
).addSyncedPaths({'/Documents': true, '/Trips': true});
await _pumpUntil(tester, find.text('Synced'), timeout: 180);
await _tapInTab(tester, filesTab, find.text('Synced'));
await tester.pump(const Duration(seconds: 1));
await shoot('08_Sync_Status');
await _tapInTab(tester, filesTab, find.byTooltip('Collapse'));
// ---- 09 Offline -----------------------------------------------------
_setVisibleTabs(tester, [
AppTab.files,
AppTab.photos,
AppTab.favorites,
AppTab.shares,
AppTab.offline,
]);
await tester.pump(const Duration(milliseconds: 500));
await _goToTab(tester, AppTab.offline);
await _pumpUntil(
tester,
find.descendant(
of: find.byKey(const ValueKey('offline')),
matching: find.text('Documents'),
),
timeout: 120,
);
await shoot('09_Offline');
// ---- 10/11 Settings -------------------------------------------------
await _tap(tester, find.byTooltip('Settings').hitTestable().first);
await _pumpUntil(tester, find.text('Accounts'));
await shoot('10_Settings');
// Wait for it, rather than assuming it's already built - ensureVisible
// itself has no retry/timeout, so on any transient delay (e.g. still
// mid-route-transition) it fails immediately instead of giving the
// frame a moment to catch up.
await _pumpUntil(tester, find.text('Files Cache'));
await tester.ensureVisible(find.text('Files Cache'));
await tester.pump(const Duration(milliseconds: 300));
await shoot('11_Settings_Sync');
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
await tester.pump(const Duration(seconds: 1));
// ---- 12/13 Dark theme -------------------------------------------------
final settings = _read<SettingsController>(tester);
settings.setThemeMode(ThemeMode.dark);
await _goToTab(tester, AppTab.files);
await tester.pump(const Duration(seconds: 2));
await shoot('12_Files_Dark');
await _goToTab(tester, AppTab.photos);
await tester.pump(const Duration(seconds: 3));
await shoot('13_Photos_Dark');
// ---- 14/15 Dark theme, AMOLED (pure black) -----------------------------
settings.setAmoledDark(true);
await tester.pump(const Duration(seconds: 1));
await _goToTab(tester, AppTab.files);
await tester.pump(const Duration(seconds: 2));
await shoot('14_Files_Dark_Amoled');
await _goToTab(tester, AppTab.photos);
await tester.pump(const Duration(seconds: 3));
await shoot('15_Photos_Dark_Amoled');
},
timeout: const Timeout(Duration(minutes: 15)),
);
}
/// Starts from empty storage, then writes exactly one saved account (the
/// demo user, with the app password from `--dart-define`) and the settings
/// that make screenshots reproducible: light theme, a fixed brand colour
/// (dynamic colour would pick up the emulator's wallpaper), five bottom-nav
/// tabs, and no first-run notification prompt.
Future<void> _seedLoggedInStorage() async {
await const FlutterSecureStorage().deleteAll();
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
final store = AccountStore();
final account = SavedAccount(
id: SavedAccount.makeId(_serverUrl, _username),
serverUrl: _serverUrl,
username: _username,
);
await store.saveAccounts(prefs, [account]);
await store.saveActiveAccountId(prefs, account.id);
await store.writePassword(account.id, _appPassword);
await prefs.setBool('notification_permission_asked', true);
await prefs.setString('ui_theme_mode', ThemeMode.light.name);
await prefs.setBool('ui_use_dynamic_color', false);
await prefs.setStringList('ui_tab_order', [
for (final t in AppTab.values) t.name,
]);
await prefs.setStringList('ui_hidden_tabs', [
AppTab.trash.name,
AppTab.recent.name,
AppTab.offline.name,
]);
}
T _read<T>(WidgetTester tester) => Provider.of<T>(
tester.element(find.byType(app.MainShellView)),
listen: false,
);
Future<void> _tap(WidgetTester tester, Finder finder) async {
await tester.tap(finder);
await tester.pump(const Duration(milliseconds: 600));
}
/// Taps the one widget matching [matching] inside [tab] (e.g. `filesTab`) -
/// for chrome that's duplicated across tabs (`IndexedStack` keeps every
/// visible tab's widget tree mounted, not just the active one) and/or can be
/// scrolled out of its own row's clipped viewport (the controls row,
/// `FilesControlsRow`, doesn't fit every control on a narrow screen).
/// Scoping to [tab] picks the right one of several identical matches;
/// `ensureVisible` scrolls it into view before tapping, which plain
/// `.hitTestable()` can't distinguish from "on the wrong tab" - both look
/// like "zero hit-testable matches" and throw the same unhelpful
/// `Bad state: No element`.
Future<void> _tapInTab(WidgetTester tester, Finder tab, Finder matching) async {
final target = find.descendant(of: tab, matching: matching).first;
await tester.ensureVisible(target);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(target, warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 600));
}
Future<void> _goToTab(WidgetTester tester, AppTab tab) async {
// Only one bottom nav bar exists (outside the IndexedStack), so no
// cross-tab duplicate to worry about - but it can itself overflow a
// narrow screen, so still scroll the target into view rather than relying
// on hitTestable() alone (see `_tapInTab`'s doc comment).
final target = find
.descendant(
of: find.byType(BottomNavBar),
matching: find.byIcon(tab.icon),
)
.first;
await tester.ensureVisible(target);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(target, warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 600));
// Confirm the tap actually landed, rather than trusting it silently - a
// scroll-imprecise tap (see above) that misses its target doesn't throw,
// it just does nothing, which would otherwise leave every later
// screenshot quietly showing whatever tab was already selected instead of
// failing loudly. Every destination shows its label all the time now (see
// `BottomNavBar`'s doc comment), so label presence can't signal selection
// - the icon's color can: it's `accentText` only for the active tab.
await _pumpUntilTrue(tester, () {
if (target.evaluate().isEmpty) return false;
final icon = tester.widget<Icon>(target);
final colors = tester.element(target).nooColors;
return icon.color == colors.accentText;
}, timeout: 10);
}
/// Shows exactly [tabs] in the bottom nav (hiding the rest first, so the
/// five-tab cap is never hit).
void _setVisibleTabs(WidgetTester tester, List<AppTab> tabs) {
final settings = _read<SettingsController>(tester);
for (final tab in AppTab.values) {
if (!tabs.contains(tab)) settings.setTabHidden(tab, true);
}
for (final tab in tabs) {
settings.setTabHidden(tab, false);
}
}
/// Waits (in real time - this is a live binding, and the app is talking to a
/// real server) until [finder] matches something.
Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
int timeout = 60,
}) async {
final deadline = DateTime.now().add(Duration(seconds: timeout));
while (finder.evaluate().isEmpty) {
if (DateTime.now().isAfter(deadline)) {
throw TestFailure('Timed out after ${timeout}s waiting for $finder');
}
await tester.pump(const Duration(milliseconds: 250));
}
}
/// Like [_pumpUntil], but for a condition that isn't just "does a finder
/// match something" (e.g. a widget property).
Future<void> _pumpUntilTrue(
WidgetTester tester,
bool Function() predicate, {
int timeout = 60,
}) async {
final deadline = DateTime.now().add(Duration(seconds: timeout));
while (!predicate()) {
if (DateTime.now().isAfter(deadline)) {
throw TestFailure('Timed out after ${timeout}s waiting for condition');
}
await tester.pump(const Duration(milliseconds: 250));
}
}
+3
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
@@ -44,6 +45,8 @@ import 'widgets/shell/shell_common.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Fonts ship in assets/google_fonts; never fetch them from Google at runtime.
GoogleFonts.config.allowRuntimeFetching = false;
runApp(
MultiProvider(
// Split out of the former single `ServerProvider` god object - see
+13
View File
@@ -147,6 +147,19 @@ class NextcloudService {
return response.bodyBytes;
}
/// Overwrites the file at [itemPath] with [bytes] (WebDAV PUT) - used by
/// the in-app text editor. Returns whether the server accepted it.
Future<bool> putBytes(String itemPath, List<int> bytes) async {
final response = await http.put(
Uri.parse(fileUrl(itemPath)),
headers: _headers,
body: bytes,
);
return response.statusCode == 200 ||
response.statusCode == 201 ||
response.statusCode == 204;
}
Future<bool> testConnection() async {
try {
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
+1
View File
@@ -206,6 +206,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
style: NooText.title.copyWith(height: 1, color: colors.fg1),
),
),
const SizedBox(width: NooSpace.md),
Expanded(child: androidTitleTrailing!),
] else
Expanded(
@@ -64,6 +64,8 @@ class NooPersonAccessRow extends StatelessWidget {
final String permission;
final VoidCallback? onPermissionTap;
final String ownerLabel;
/// Replaces the owner label / permission pill (e.g. a plain search result).
final Widget? trailing;
const NooPersonAccessRow({
super.key,
@@ -74,6 +76,7 @@ class NooPersonAccessRow extends StatelessWidget {
this.permission = 'Can edit',
this.onPermissionTap,
this.ownerLabel = 'Owner',
this.trailing,
});
@override
@@ -113,7 +116,9 @@ class NooPersonAccessRow extends StatelessWidget {
),
),
const SizedBox(width: NooSpace.sm),
if (owner)
if (trailing != null)
trailing!
else if (owner)
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
else
NooPermissionPill(label: permission, onTap: onPermissionTap),
+13 -2
View File
@@ -106,13 +106,23 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
@override
Widget build(BuildContext context) {
return SafeArea(
// A modal bottom sheet doesn't avoid the keyboard itself, so a focused
// field would be covered. Inset by the keyboard and, while it's up, let
// the content use all the space above it.
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
final keyboardUp = keyboard > 0;
return Padding(
padding: EdgeInsets.only(bottom: keyboard),
child: SafeArea(
bottom: !keyboardUp,
child: LayoutBuilder(
builder: (context, constraints) {
if (!_needsPeek) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction,
maxHeight: keyboardUp
? constraints.maxHeight
: constraints.maxHeight * _kNooSheetPeekFraction,
),
child: _buildScrollable(null),
);
@@ -141,6 +151,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
);
},
),
),
);
}
}
+77 -14
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -71,11 +72,13 @@ class ShareSheet extends StatefulWidget {
return showNooSheet(
context,
children: [
NooOverlayHeader(
Builder(
builder: (sheetContext) => NooOverlayHeader(
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
onClose: () => Navigator.pop(context),
onClose: () => Navigator.of(sheetContext).pop(),
),
),
ShareSheet(item: item),
],
@@ -99,9 +102,36 @@ class _ShareSheetState extends State<ShareSheet> {
bool _showInherited = false;
final _peopleController = TextEditingController();
final _linkController = TextEditingController();
final _peopleFocus = FocusNode();
final _peopleKey = GlobalKey();
/// Once the user types, scrolls the sheet so the people field and its
/// results (under their title) sit near the top, leaving a little room under the sheet's
/// rounded top edge.
void _revealPeopleSection() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final ctx = _peopleKey.currentContext;
final box = ctx?.findRenderObject();
if (ctx == null || box is! RenderBox) return;
final position = Scrollable.maybeOf(ctx)?.position;
final viewport = RenderAbstractViewport.maybeOf(box);
if (position == null || viewport == null) return;
final target = (viewport.getOffsetToReveal(box, 0).offset - 8).clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
position.animateTo(
target,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
@override
void dispose() {
_peopleFocus.dispose();
_peopleController.dispose();
_linkController.dispose();
super.dispose();
@@ -128,6 +158,7 @@ class _ShareSheetState extends State<ShareSheet> {
return;
}
setState(() => _isSearching = true);
_revealPeopleSection();
final ops = context.read<ItemOperations>();
final results = await ops.searchSharees(trimmed);
if (!mounted) return;
@@ -135,6 +166,7 @@ class _ShareSheetState extends State<ShareSheet> {
_searchResults = results;
_isSearching = false;
});
_revealPeopleSection();
}
/// The single "Name, email or group" field: reuses whichever match is
@@ -364,6 +396,7 @@ class _ShareSheetState extends State<ShareSheet> {
absorbing: _isAddingPerson,
child: NooTextField(
controller: _peopleController,
focusNode: _peopleFocus,
leadingIcon: LucideIcons.search,
placeholder: 'Name, email or group',
onChanged: _search,
@@ -381,17 +414,34 @@ class _ShareSheetState extends State<ShareSheet> {
),
),
for (final sharee in _searchResults)
NooSettingsRow(
InkWell(
borderRadius: BorderRadius.circular(NooRadii.input),
onTap: () => _addSharee(sharee),
child: NooPersonAccessRow(
avatar: NooAvatar(
initials: _initial(sharee.label),
color: NooPhotoTile.paletteColor(sharee.label.hashCode),
icon: sharee.type == ShareeType.group
? LucideIcons.users
: LucideIcons.user,
label: Text(sharee.label),
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
onTap: () => _addSharee(sharee),
: null,
),
name: sharee.label,
subtitle: sharee.subtitle,
trailing: const SizedBox.shrink(),
),
),
if (_searchResults.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: NooSpace.xs),
child: Divider(height: 1, thickness: 1, color: colors.line),
)
else
const SizedBox(height: NooSpace.xs),
NooPersonAccessRow(
avatar: NooAvatar(initials: _initial(session.username), current: true),
avatar: NooAvatar(
initials: _initial(session.username),
current: true,
),
name: session.username,
owner: true,
),
@@ -402,12 +452,16 @@ class _ShareSheetState extends State<ShareSheet> {
color: NooPhotoTile.paletteColor(
(share.sharedWithDisplayName ?? share.id).hashCode,
),
icon: share.shareType == ShareType.group ? LucideIcons.users : null,
icon: share.shareType == ShareType.group
? LucideIcons.users
: null,
),
name: share.sharedWithDisplayName ?? 'Shared',
subtitle: share.shareType == ShareType.group
? 'Group'
: (share.shareType == ShareType.email ? 'Invited by email' : null),
: (share.shareType == ShareType.email
? 'Invited by email'
: null),
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
onPermissionTap: () => _openPersonMenu(share),
),
@@ -429,7 +483,9 @@ class _ShareSheetState extends State<ShareSheet> {
),
),
Icon(
_showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown,
_showInherited
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 16,
color: colors.fg3,
),
@@ -464,7 +520,10 @@ class _ShareSheetState extends State<ShareSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)),
Text(
'Internal link',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
Text(
'For people who already have access',
style: NooText.meta.copyWith(color: colors.fg3),
@@ -559,6 +618,7 @@ class _ShareSheetState extends State<ShareSheet> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooShareSection(
key: _peopleKey,
title: 'Share with people',
child: _peopleSection(colors, session, peopleShares, internalLink),
),
@@ -572,13 +632,16 @@ class _ShareSheetState extends State<ShareSheet> {
caption: hasLink
? null
: 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.',
child: hasLink ? _linkSection(publicLinkShares.first) : const SizedBox.shrink(),
child: hasLink
? _linkSection(publicLinkShares.first)
: const SizedBox.shrink(),
),
if (!widget.item.isFolder) ...[
const SizedBox(height: 22),
NooShareSection(
title: 'Send file directly',
caption: "Link settings above don't apply - this sends the file's bytes directly.",
caption:
"Link settings above don't apply - this sends the file's bytes directly.",
child: NooButton(
variant: NooButtonVariant.outline,
size: NooButtonSize.card,
+154 -10
View File
@@ -1,12 +1,25 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../models/nextcloud_item.dart';
import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart';
import '../noo/core/noo_button.dart';
/// Plain monospace text-file preview for [FileViewerScreen]'s static (non-
/// swipeable) preview path (`_textPreviewExtensions`).
/// Space the viewer's floating top bar and bottom action bar cover, so the
/// text sits clear of them (and the status bar) instead of scrolling
/// underneath.
const double _kTopBarClearance = 72;
const double _kActionBarClearance = 112;
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
/// editable when online; a Save button appears once it has changed. Markdown
/// files open rendered, with an Edit/Preview toggle. Opened
/// from the Offline tab ([localPathResolver] set) it is read-only, since the
/// local copy has no server to write back to.
class MediaTextPreview extends StatefulWidget {
final NextcloudItem item;
final SessionController session;
@@ -28,15 +41,35 @@ class MediaTextPreview extends StatefulWidget {
}
class _MediaTextPreviewState extends State<MediaTextPreview> {
String? _content;
final _controller = TextEditingController();
String? _saved;
String? _error;
bool _isSaving = false;
bool _editing = false;
bool get _isMarkdown {
final name = widget.item.name.toLowerCase();
return name.endsWith('.md') || name.endsWith('.markdown');
}
bool get _showRendered => _isMarkdown && !_editing;
bool get _readOnly => widget.localPathResolver != null;
bool get _dirty => _saved != null && _controller.text != _saved;
@override
void initState() {
super.initState();
_controller.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final List<int> bytes;
@@ -53,13 +86,39 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
bytes = await widget.session.service!.fetchBytes(widget.item.path);
}
if (mounted) {
setState(() => _content = utf8.decode(bytes, allowMalformed: true));
final text = utf8.decode(bytes, allowMalformed: true);
_controller.text = text;
setState(() => _saved = text);
}
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
Future<void> _save() async {
final messenger = ScaffoldMessenger.of(context);
final text = _controller.text;
setState(() => _isSaving = true);
var ok = false;
try {
ok = await widget.session.service!.putBytes(
widget.item.path,
utf8.encode(text),
);
} catch (_) {}
if (!mounted) return;
setState(() {
_isSaving = false;
if (ok) _saved = text;
});
messenger.showSnackBar(
SnackBar(
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
@@ -71,18 +130,103 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
),
);
}
if (_content == null) {
if (_saved == null) {
return Center(child: CircularProgressIndicator(color: colors.accent));
}
final safe = MediaQuery.paddingOf(context);
final style = NooText.mono.copyWith(color: colors.fg1, height: 1.5);
final padding = EdgeInsets.fromLTRB(
20,
safe.top + _kTopBarClearance,
20,
safe.bottom + _kActionBarClearance,
);
return Container(
color: colors.surface,
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: SelectableText(
_content!,
style: NooText.mono.copyWith(color: colors.fg1, height: 1.5),
child: Stack(
children: [
Positioned.fill(
child: _showRendered
? Markdown(
data: _controller.text,
padding: padding,
selectable: true,
styleSheet: _markdownStyle(context, colors),
onTapLink: (_, href, _) {},
)
: SingleChildScrollView(
padding: padding,
child: TextField(
controller: _controller,
readOnly: _readOnly,
maxLines: null,
keyboardType: TextInputType.multiline,
style: style,
cursorColor: colors.accentText,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
),
if (_isMarkdown && !_readOnly)
Positioned(
right: 20,
top: safe.top + _kTopBarClearance - 8,
child: NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.compact,
icon: _editing ? LucideIcons.eye : LucideIcons.pencil,
onTap: () => setState(() => _editing = !_editing),
child: Text(_editing ? 'Preview' : 'Edit'),
),
),
if (_dirty)
Positioned(
right: 20,
bottom: safe.bottom + _kActionBarClearance,
child: NooButton(
size: NooButtonSize.card,
icon: LucideIcons.save,
disabled: _isSaving,
onTap: _isSaving ? null : _save,
child: Text(_isSaving ? 'Saving…' : 'Save'),
),
),
],
),
);
}
}
MarkdownStyleSheet _markdownStyle(BuildContext context, NooColors colors) {
final body = NooText.body.copyWith(color: colors.fg1, height: 1.55);
TextStyle heading(double size) =>
NooText.title.copyWith(color: colors.fg1, fontSize: size, height: 1.25);
return MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
p: body,
h1: heading(26),
h2: heading(22),
h3: heading(19),
h4: heading(17),
listBullet: body,
a: body.copyWith(color: colors.accentText),
blockquote: body.copyWith(color: colors.fg2),
blockquoteDecoration: BoxDecoration(
border: Border(left: BorderSide(color: colors.line, width: 3)),
),
code: NooText.mono.copyWith(
color: colors.fg1,
backgroundColor: colors.surface2,
),
codeblockDecoration: BoxDecoration(
color: colors.surface2,
borderRadius: BorderRadius.circular(NooRadii.input),
),
horizontalRuleDecoration: BoxDecoration(
border: Border(top: BorderSide(color: colors.line)),
),
);
}
+16 -39
View File
@@ -254,11 +254,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -267,6 +262,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_markdown_plus:
dependency: "direct main"
description:
name: flutter_markdown_plus
sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
url: "https://pub.dev"
source: hosted
version: "1.0.12"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -333,11 +336,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
google_fonts:
dependency: "direct main"
description:
@@ -378,11 +376,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl:
dependency: "direct main"
description:
@@ -503,6 +496,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.20"
markdown:
dependency: transitive
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.dev"
source: hosted
version: "7.3.1"
marquee:
dependency: "direct main"
description:
@@ -791,14 +792,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
provider:
dependency: "direct main"
description:
@@ -932,14 +925,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized:
dependency: transitive
description:
@@ -1084,14 +1069,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
webview_flutter:
dependency: "direct main"
description:
+3 -6
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.0.1+2
version: 1.0.0+3
environment:
sdk: ^3.12.2
@@ -58,16 +58,12 @@ dependencies:
webview_flutter: ^4.14.1
permission_handler: ^11.3.1
connectivity_plus: ^6.1.5
flutter_markdown_plus: ^1.0.12
dev_dependencies:
flutter_test:
sdk: flutter
# Drives tool/screenshots.sh (Play Store screenshots against a fake-data
# demo server) - dev only, never part of the release build.
integration_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
@@ -89,6 +85,7 @@ flutter:
assets:
- assets/icon/app_icon.png
- assets/icon/app_icon_monochrome.png
- assets/google_fonts/
# To add assets to your application, add an assets section, like this:
# assets:
-33
View File
@@ -1,33 +0,0 @@
// Host-side driver for integration_test/store_screenshots_test.dart (run via
// tool/screenshots.sh). Each time the test asks for a screenshot this saves
// a PNG named after it into $SCREENSHOT_DIR.
//
// These bytes are the Flutter surface only (no OS status bar) - an earlier
// version of this file tried to re-capture the screen with `adb exec-out
// screencap` for a "real" screenshot including it, but on Android,
// `onScreenshot` only fires once the whole test has finished (see
// `IntegrationTestWidgetsFlutterBinding`/`integrationDriver`'s source: the
// native platform channel hands screenshot bytes straight to `reportData`
// with no round trip to the driver, and the driver's first `requestData`
// call blocks until every `testWidgets` body in the file has returned) - so
// every `adb screencap` call was capturing the same final frame, regardless
// of which named screenshot it was meant to be. Saving these bytes directly
// is what actually gives each name its own distinct image.
import 'dart:io';
import 'package:integration_test/integration_test_driver_extended.dart';
Future<void> main() async {
final outDir = Directory(
Platform.environment['SCREENSHOT_DIR'] ?? 'store_listing/screenshots',
)..createSync(recursive: true);
await integrationDriver(
onScreenshot:
(String name, List<int> bytes, [Map<String, Object?>? args]) async {
File('${outDir.path}/$name.png').writeAsBytesSync(bytes);
stdout.writeln('saved $name.png');
return true;
},
);
}
-48
View File
@@ -1,48 +0,0 @@
# Demo server for Play Store screenshots
`tool/screenshots.sh` takes the Play Store screenshots against a **throwaway
Nextcloud that only contains fake data**, so nothing real can appear in them.
This folder is that server:
- `docker-compose.yml` - a single Nextcloud container (SQLite, nothing
persisted; `docker compose down -v` wipes it).
- `seed.py` - creates the users and fills them with invented content: landscape
"photos" drawn with Pillow, tiny generated PDFs, made-up documents, favorites,
shares (to a second user and a public link), trash, a filler file for the
storage quota, and edits by a second user so the Activity feed has more than
one author. It ends by printing `APP_PASSWORD=...` - an app password for the
demo user, so the app can start logged in without the browser login step.
## Running it
Most of the time just run the whole pipeline from the repo root:
```bash
bash tool/screenshots.sh
```
(Git Bash is fine on Windows.) You need **Docker**, **Python 3**, **Flutter**
and an **Android SDK** (`ANDROID_HOME`). Pillow and `requests` are installed
into `tool/demo_server/.venv` automatically if your Python lacks them. The
script installs the emulator and a system image through `sdkmanager` on first
use. Options (`HEADLESS=1`, `DEMO_PORT`, `DEMO_HOST`, `KEEP_SERVER=1`,
`ANDROID_SERIAL`, ...) are documented at the top of the script.
To poke at the server yourself:
```bash
docker compose -f tool/demo_server/docker-compose.yml up -d
python3 tool/demo_server/seed.py # prints APP_PASSWORD=...
# browse http://localhost (Alex / alex-demo-pass, admin / demo-admin-password)
docker compose -f tool/demo_server/docker-compose.yml down -v
```
`python3 tool/demo_server/seed.py --generate-only DIR` writes the generated
files to `DIR` without any server - handy for previewing the fake content.
## Before you upload
Look at every image. The known places where real data could still show up:
the server address and username (sync header panel, Settings > Accounts), the
avatar initial, notification icons in the status bar, and anything left over
from a previously used emulator profile (the script always wipes its own).
-16
View File
@@ -1,16 +0,0 @@
# A throwaway Nextcloud that only ever holds fake data - the backend the Play
# Store screenshots are taken against (see tool/screenshots.sh). Nothing is
# persisted: `docker compose down -v` wipes it completely.
services:
nextcloud:
image: ${NEXTCLOUD_IMAGE:-nextcloud:apache}
ports:
- "${DEMO_PORT:-80}:80"
environment:
# SQLite keeps this to a single container.
SQLITE_DATABASE: nextcloud
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: demo-admin-password
# `localhost` is how the Android emulator reaches this server (via
# `adb reverse`); DEMO_HOST is the optional friendlier hostname.
NEXTCLOUD_TRUSTED_DOMAINS: "localhost 127.0.0.1 10.0.2.2 ${DEMO_HOST:-demo.example.com}"
-2
View File
@@ -1,2 +0,0 @@
requests>=2.31
Pillow>=10.0
-348
View File
@@ -1,348 +0,0 @@
#!/usr/bin/env python3
"""Fills a throwaway Nextcloud with fake data for the Play Store screenshots.
Everything here is invented or procedurally generated (landscape "photos"
drawn with Pillow, tiny hand-built PDFs, made-up names), so nothing real -
and nothing with a licence or a face in it - can end up in a screenshot.
python3 seed.py seed http://localhost (DEMO_BASE_URL)
python3 seed.py --generate-only D write the fake files under D, no server
On success the LAST line printed to stdout is `APP_PASSWORD=<token>` - an app
password for the demo user, which tool/screenshots.sh hands to the app so it
can start logged in without going through the browser login flow. All
progress output goes to stderr.
"""
import argparse
import io
import math
import os
import random
import shutil
import subprocess
import sys
import tempfile
import time
from urllib.parse import quote
from PIL import Image, ImageDraw, ImageFilter
BASE = os.environ.get("DEMO_BASE_URL", "http://localhost").rstrip("/")
ADMIN = ("admin", os.environ.get("NEXTCLOUD_ADMIN_PASSWORD", "demo-admin-password"))
FILLER_MB = int(os.environ.get("DEMO_FILLER_MB", "300"))
# The account the screenshots are taken as, and a second user to share with.
USERS = {
"Alex": ("alex-demo-pass", "Alex Morgan", "alex@example.com", "15 GB"),
"Sam": ("sam-demo-pass", "Sam Rivera", "sam@example.com", "5 GB"),
}
def log(message):
print(message, file=sys.stderr, flush=True)
# --------------------------------------------------------------- content ---
# (sky top, sky bottom, sun, far hills, mid hills, near hills)
PALETTES = [
((250, 176, 120), (255, 226, 170), (255, 250, 220), (152, 120, 150), (108, 90, 128), (62, 60, 96)),
((70, 130, 200), (190, 225, 245), (255, 255, 240), (110, 150, 170), (70, 118, 130), (40, 84, 90)),
((30, 40, 90), (240, 130, 110), (255, 220, 190), (90, 60, 110), (60, 44, 84), (30, 28, 56)),
((110, 180, 190), (230, 245, 235), (255, 245, 200), (130, 180, 150), (84, 140, 110), (44, 98, 76)),
((240, 150, 90), (255, 214, 150), (255, 240, 200), (200, 130, 90), (160, 96, 70), (110, 66, 56)),
((20, 24, 60), (70, 60, 120), (240, 240, 255), (50, 50, 100), (34, 36, 80), (18, 20, 50)),
((150, 200, 230), (245, 250, 255), (255, 255, 255), (170, 190, 210), (130, 156, 180), (90, 120, 150)),
((200, 120, 160), (250, 200, 190), (255, 235, 210), (170, 100, 140), (126, 78, 116), (80, 54, 90)),
]
def _mix(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
def landscape(seed, size=(1600, 1067)):
"""A soft, layered-hills landscape - reads as a photo at thumbnail size."""
rnd = random.Random(seed)
top, bottom, sun, far, mid, near = PALETTES[seed % len(PALETTES)]
w, h = size
img = Image.new("RGB", size)
draw = ImageDraw.Draw(img)
for y in range(h):
draw.line([(0, y), (w, y)], fill=_mix(top, bottom, min(1.0, y / (h * 0.75))))
sx, sy, sr = rnd.randint(w // 5, 4 * w // 5), rnd.randint(h // 6, h // 3), rnd.randint(h // 12, h // 7)
draw.ellipse([sx - sr, sy - sr, sx + sr, sy + sr], fill=sun)
for base, amp, color in ((0.62, 0.10, far), (0.72, 0.13, mid), (0.84, 0.10, near)):
phase = [rnd.uniform(0, math.tau) for _ in range(3)]
freq = [rnd.uniform(1.0, 2.0), rnd.uniform(2.5, 4.0), rnd.uniform(5.0, 8.0)]
weight = (1.0, 0.5, 0.2)
pts = [(0, h)]
for x in range(0, w + 8, 8):
v = sum(wt * math.sin(x / w * math.tau * f + p) for wt, f, p in zip(weight, freq, phase))
pts.append((x, int(h * base + h * amp * v / 1.7)))
pts.append((w, h))
draw.polygon(pts, fill=color)
img = img.filter(ImageFilter.GaussianBlur(0.8))
out = io.BytesIO()
img.save(out, "JPEG", quality=86)
return out.getvalue()
def pdf(title, lines):
"""A tiny but valid one-page PDF."""
def esc(s):
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
body = "BT /F1 26 Tf 72 720 Td (%s) Tj ET\n" % esc(title)
for i, line in enumerate(lines):
body += "BT /F1 12 Tf 72 %d Td (%s) Tj ET\n" % (680 - 20 * i, esc(line))
stream = body.encode("latin-1", "replace")
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
b"/Resources << /Font << /F1 5 0 R >> >> >>",
b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
out = bytearray(b"%PDF-1.4\n")
offsets = []
for i, obj in enumerate(objs, start=1):
offsets.append(len(out))
out += b"%d 0 obj\n" % i + obj + b"\nendobj\n"
xref = len(out)
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
for off in offsets:
out += b"%010d 00000 n \n" % off
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
return bytes(out)
def text(body):
return body.strip().encode("utf-8") + b"\n"
def video():
"""A short test-pattern clip if ffmpeg is around, else None (skipped)."""
if not shutil.which("ffmpeg"):
return None
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "clip.mp4")
result = subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=duration=6:size=640x360:rate=24",
"-pix_fmt", "yuv420p", path],
capture_output=True,
)
if result.returncode != 0:
return None
with open(path, "rb") as f:
return f.read()
def build_content():
"""(path, bytes) for everything the demo user owns, plus what to favorite/delete."""
files = []
def add(path, data):
if data is not None:
files.append((path, data))
add("/Welcome.md", text("# Welcome\n\nYour files, everywhere you go."))
add("/Vacation ideas.txt", text("Lisbon\nKyoto\nPatagonia\nIceland in winter"))
add("/Documents/Project brief.pdf", pdf("Project brief", ["Goals for the quarter", "Milestones and owners", "Open questions"]))
add("/Documents/Quarterly report.pdf", pdf("Quarterly report", ["Revenue up 12 percent", "Churn down 3 percent", "Hiring plan"]))
add("/Documents/Meeting notes.md", text("# Meeting notes\n\n- Ship the beta on Friday\n- Review pricing\n- Book the offsite"))
add("/Documents/Budget 2026.xlsx", os.urandom(24_000))
add("/Documents/Contracts/Lease agreement.pdf", pdf("Lease agreement", ["Term: 12 months", "Rent due on the 1st"]))
add("/Documents/Contracts/NDA - template.pdf", pdf("Mutual NDA", ["Standard template"]))
trips = ["Lake sunrise", "Mountain pass", "Old town", "Beach day", "Desert road", "Forest trail", "City lights"]
for i, name in enumerate(trips):
add("/Trips/%s.jpg" % name, landscape(i + 1))
add("/Trips/Trip itinerary.pdf", pdf("Trip itinerary", ["Day 1 Arrive and settle in", "Day 2 Old town walk", "Day 3 Coast day trip"]))
for i in range(8):
add("/Photos/Summer 2026/IMG_%04d.jpg" % (2041 + i), landscape(20 + i))
add("/Photos/Summer 2026/Clip - hike.mp4", video())
add("/Recipes/Pasta al limone.md", text("# Pasta al limone\n\nSpaghetti, lemon, butter, parmesan, black pepper."))
add("/Recipes/Sourdough.md", text("# Sourdough\n\n500g flour, 350g water, 100g starter, 10g salt."))
add("/Recipes/Shopping list.txt", text("Lemons\nParmesan\nFlour\nCoffee beans"))
add("/Projects/Website redesign/Wireframes.pdf", pdf("Wireframes", ["Home", "Pricing", "Sign up"]))
add("/Projects/Website redesign/Design tokens.json", text('{"radius": 12, "primary": "#2a6b50"}'))
add("/Projects/Website redesign/notes.txt", text("Kickoff next Tuesday.\nCollect brand assets."))
favorites = ["/Trips/Lake sunrise.jpg", "/Trips/Mountain pass.jpg", "/Documents/Project brief.pdf", "/Recipes/Sourdough.md"]
# Created, then deleted - they end up in the Trash tab.
trashed = [
("/Old draft.txt", text("Superseded draft.")),
("/Screenshot 2026-03-14.jpg", landscape(40, (1080, 1920))),
("/Receipts (old)/March.pdf", pdf("Receipt", ["March"])),
]
return files, favorites, trashed
# ---------------------------------------------------------------- server ---
class ZeroStream:
"""`size` zero bytes, streamed - a big filler file without holding it in memory."""
def __init__(self, size):
self.size, self.pos = size, 0
def __len__(self):
return self.size
def read(self, n=-1):
n = self.size - self.pos if n is None or n < 0 else min(n, self.size - self.pos)
self.pos += n
return b"\0" * n
class Server:
def __init__(self):
import requests # imported lazily so --generate-only needs no network libs
self.requests = requests
def _call(self, method, url, auth, **kw):
headers = kw.pop("headers", {})
headers.setdefault("OCS-APIRequest", "true")
return self.requests.request(method, url, auth=auth, headers=headers, timeout=600, **kw)
def wait_ready(self):
log("Waiting for Nextcloud to finish installing (first start takes a minute or two)...")
deadline = time.time() + 600
while time.time() < deadline:
try:
r = self.requests.get(BASE + "/status.php", timeout=5)
if r.ok and r.json().get("installed") and not r.json().get("maintenance"):
return
except Exception:
pass
time.sleep(3)
sys.exit("Nextcloud did not become ready in time")
def ocs(self, method, path, auth, **kw):
r = self._call(method, BASE + path, auth, params={"format": "json"}, **kw)
try:
return r.json()["ocs"]
except Exception:
return {"meta": {"statuscode": r.status_code}, "data": {}}
def create_user(self, user, password, display, email, quota):
res = self.ocs(
"POST", "/ocs/v1.php/cloud/users", ADMIN,
data={"userid": user, "password": password, "displayName": display, "email": email, "quota": quota},
)
code = res["meta"]["statuscode"]
if code not in (100, 200, 102): # 102 = already exists
sys.exit("Could not create user %s: %s" % (user, res))
def dav(self, method, user, path, **kw):
auth = (user, USERS[user][0])
url = "%s/remote.php/dav/files/%s%s" % (BASE, user, quote(path))
return self._call(method, url, auth, **kw)
def mkdirs(self, user, path):
built = ""
for part in [p for p in path.split("/") if p]:
built += "/" + part
self.dav("MKCOL", user, built)
def put(self, user, path, data):
parent = path.rsplit("/", 1)[0]
if parent:
self.mkdirs(user, parent)
r = self.dav("PUT", user, path, data=data)
if r.status_code not in (200, 201, 204):
sys.exit("PUT %s failed: %s %s" % (path, r.status_code, r.text[:200]))
def favorite(self, user, path):
body = (
'<?xml version="1.0"?><d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
"<d:set><d:prop><oc:favorite>1</oc:favorite></d:prop></d:set></d:propertyupdate>"
)
self.dav("PROPPATCH", user, path, data=body, headers={"Content-Type": "application/xml"})
def delete(self, user, path):
self.dav("DELETE", user, path)
def share(self, user, path, share_type, share_with=None, permissions=31):
data = {"path": path, "shareType": share_type, "permissions": permissions}
if share_with:
data["shareWith"] = share_with
auth = (user, USERS[user][0])
self.ocs("POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", auth, data=data)
def app_password(self, user):
auth = (user, USERS[user][0])
res = self.ocs("GET", "/ocs/v2.php/core/getapppassword", auth)
token = (res.get("data") or {}).get("apppassword")
if not token:
sys.exit("Could not get an app password for %s: %s" % (user, res))
return token
def seed_server():
server = Server()
server.wait_ready()
for user, (password, display, email, quota) in USERS.items():
log("Creating user %s" % user)
server.create_user(user, password, display, email, quota)
files, favorites, trashed = build_content()
log("Uploading %d files as Alex" % len(files))
for path, data in files:
server.put("Alex", path, data)
if FILLER_MB > 0:
log("Uploading a %d MB filler file so the storage quota isn't empty" % FILLER_MB)
server.put("Alex", "/Backups/Laptop backup.zip", ZeroStream(FILLER_MB * 1024 * 1024))
for path in favorites:
server.favorite("Alex", path)
log("Creating shares")
server.share("Alex", "/Trips", 0, "Sam", permissions=31)
server.share("Alex", "/Documents/Project brief.pdf", 3, permissions=1)
server.put("Sam", "/Team notes/Roadmap.md", text("# Roadmap\n\nQ3: beta. Q4: launch."))
server.put("Sam", "/Team notes/Standup.md", text("Daily standup at 9:30."))
server.share("Sam", "/Team notes", 0, "Alex", permissions=31)
# Sam edits inside the folder Alex shared - gives the Activity feed a second author.
server.put("Sam", "/Trips/Suggestions.txt", text("Add the coast road stop!"))
log("Filling the trash")
for path, data in trashed:
server.put("Alex", path, data)
for path, _ in trashed:
server.delete("Alex", path)
server.delete("Alex", "/Receipts (old)")
token = server.app_password("Alex")
print("APP_PASSWORD=" + token, flush=True)
def generate_only(target):
files, favorites, trashed = build_content()
for path, data in files + trashed:
dest = os.path.join(target, "Alex" + path)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(data)
log("Wrote %d files under %s (favorites: %d)" % (len(files) + len(trashed), target, len(favorites)))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--generate-only", metavar="DIR", help="write the fake files locally instead of seeding a server")
args = parser.parse_args()
if args.generate_only:
generate_only(args.generate_only)
else:
seed_server()
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env python3
"""Makes captured screenshots Play Store ready and reports anything that isn't.
Play wants JPEG or 24-bit PNG (no alpha), each side 320-3840 px, and the long
side at most twice the short side. The captured PNGs are 32-bit RGBA, so this
flattens them to RGB in place, then checks the dimensions.
python3 tool/finalize_screenshots.py store_listing/screenshots
"""
import sys
from pathlib import Path
from PIL import Image
def main(directory):
files = sorted(Path(directory).glob("*.png"))
if not files:
sys.exit("No PNGs found in %s" % directory)
problems = 0
for path in files:
with Image.open(path) as im:
im.load()
rgb = Image.new("RGB", im.size, (255, 255, 255))
rgb.paste(im, mask=im.getchannel("A") if "A" in im.getbands() else None)
rgb.save(path, "PNG", optimize=True)
w, h = rgb.size
notes = []
if min(w, h) < 320 or max(w, h) > 3840:
notes.append("side outside 320-3840 px")
if max(w, h) > 2 * min(w, h):
notes.append("aspect ratio over 2:1")
problems += bool(notes)
print("%-32s %4dx%-4d %s" % (path.name, w, h, "; ".join(notes) or "ok"))
print("\n%d screenshots, %d with problems" % (len(files), problems))
sys.exit(1 if problems else 0)
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "store_listing/screenshots")
-273
View File
@@ -1,273 +0,0 @@
#!/usr/bin/env bash
# Generates the Play Store screenshots - without any real data.
#
# What it does, end to end:
# 1. starts a throwaway Nextcloud in Docker and fills it with fake data
# 2. creates + boots a clean Android emulator (1080x1920, demo-mode status bar)
# 3. runs integration_test/store_screenshots_test.dart against it, which
# logs in as the fake user and walks through the app's tabs and states
# 4. leaves flattened, size-checked PNGs in store_listing/screenshots/
#
# Works on Linux, macOS and Git Bash on Windows. Needs: docker, python3,
# flutter, and an Android SDK (ANDROID_HOME / ANDROID_SDK_ROOT). See
# tool/demo_server/README.md.
#
# Options (environment variables):
# SCREENSHOT_DIR output directory (store_listing/screenshots)
# ANDROID_SERIAL use this already-running device instead of the emulator
# HEADLESS=1 run the emulator without a window
# DEMO_PORT host port for the demo server (80; anything else shows
# up as ":port" in the app's header)
# DEMO_HOST show this hostname instead of "localhost" in the app,
# e.g. cloud.example.com (best effort - edits the
# emulator's hosts file, which needs a rootable image)
# KEEP_SERVER=1 leave the demo server running afterwards
# KEEP_EMULATOR=1 leave the emulator running afterwards
# SCREEN_W / SCREEN_H / SCREEN_DENSITY emulator screen (1080 / 1920 / 440);
# Play needs the long side <= 2x the short side
set -euo pipefail
cd "$(dirname "$0")/.."
REPO_ROOT="$PWD"
# Git Bash would otherwise rewrite device paths such as /system/etc/hosts.
export MSYS_NO_PATHCONV=1
AVD_NAME="${AVD_NAME:-noo_screenshots}"
API_LEVEL="${SCREENSHOT_API:-36}"
SYSTEM_IMAGE="system-images;android-${API_LEVEL};google_apis;x86_64"
SCREEN_W="${SCREEN_W:-1080}"
SCREEN_H="${SCREEN_H:-1920}"
SCREEN_DENSITY="${SCREEN_DENSITY:-440}"
OUT_DIR="${SCREENSHOT_DIR:-store_listing/screenshots}"
DEMO_PORT="${DEMO_PORT:-80}"
DEMO_HOST="${DEMO_HOST:-}"
export DEMO_PORT DEMO_HOST
log() { printf '\n==> %s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
# Two easy mistakes worth catching up front rather than as a confusing
# "missing prerequisites" list.
if [ "$(id -u)" = 0 ]; then
die "don't run this as root/with sudo - it would create root-owned files and use root's home for the emulator."
fi
if grep -qi microsoft /proc/version 2>/dev/null; then
die "this is WSL, which can't see your Windows Flutter/Android SDK (and can't run the emulator well). On Windows run it from Git Bash instead."
fi
# ---------------------------------------------------------------- tooling ---
# Windows env vars hold C:\... paths; convert for bash.
to_unix() { if command -v cygpath >/dev/null 2>&1; then cygpath -u "$1"; else printf '%s' "$1"; fi; }
SDK_ROOT=""
for candidate in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Android/Sdk" \
"$HOME/Library/Android/sdk" "${LOCALAPPDATA:-}/Android/Sdk"; do
if [ -n "$candidate" ] && [ -d "$(to_unix "$candidate")" ]; then
SDK_ROOT="$(to_unix "$candidate")"
break
fi
done
# Finds a tool by name on PATH or in the SDK, including Windows .bat/.exe.
find_tool() {
local name="$1" dir ext
for ext in "" .bat .exe; do
if command -v "$name$ext" >/dev/null 2>&1; then command -v "$name$ext"; return 0; fi
done
for dir in "$SDK_ROOT/cmdline-tools/latest/bin" "$SDK_ROOT/platform-tools" "$SDK_ROOT/emulator"; do
for ext in "" .bat .exe; do
if [ -x "$dir/$name$ext" ]; then printf '%s' "$dir/$name$ext"; return 0; fi
done
done
return 1
}
missing=()
require() { # <command> <how to get it>
command -v "$1" >/dev/null 2>&1 || missing+=("$1 - $2")
}
require docker "install Docker (Desktop on Windows/macOS)"
require flutter "install Flutter and put it on PATH"
PY="$(command -v python3 || command -v python || true)"
[ -n "$PY" ] || missing+=("python3 - install Python 3")
ADB="$(find_tool adb || true)"
[ -n "$ADB" ] || missing+=("adb - install Android platform-tools (or set ANDROID_HOME)")
if [ -z "${ANDROID_SERIAL:-}" ]; then
SDKMANAGER="$(find_tool sdkmanager || true)"
AVDMANAGER="$(find_tool avdmanager || true)"
[ -n "$SDKMANAGER" ] && [ -n "$AVDMANAGER" ] ||
missing+=("sdkmanager/avdmanager - install the Android cmdline-tools (or set ANDROID_HOME)")
fi
if [ "${#missing[@]}" -gt 0 ]; then
printf 'Missing prerequisites:\n' >&2
printf ' - %s\n' "${missing[@]}" >&2
exit 1
fi
if docker compose version >/dev/null 2>&1; then
DC=(docker compose)
else
DC=(docker-compose)
fi
DC+=(-f tool/demo_server/docker-compose.yml)
adb() { "$ADB" "$@"; }
# Python with Pillow + requests: use the system one if it has them, else a
# local virtualenv (tool/demo_server/.venv, gitignored).
if ! "$PY" -c 'import PIL, requests' >/dev/null 2>&1; then
log "Setting up a Python virtualenv (Pillow, requests)"
"$PY" -m venv tool/demo_server/.venv
if [ -x tool/demo_server/.venv/bin/python ]; then
PY="$REPO_ROOT/tool/demo_server/.venv/bin/python"
else
PY="$REPO_ROOT/tool/demo_server/.venv/Scripts/python"
fi
"$PY" -m pip install -q -r tool/demo_server/requirements.txt
fi
# ---------------------------------------------------------------- cleanup ---
STARTED_EMULATOR=0
EMULATOR_PID=""
cleanup() {
local code=$?
set +e
if [ -n "${SERIAL:-}" ]; then
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1
adb -s "$SERIAL" reverse --remove-all >/dev/null 2>&1
fi
if [ "$STARTED_EMULATOR" = 1 ] && [ "${KEEP_EMULATOR:-0}" != 1 ]; then
adb -s "$SERIAL" emu kill >/dev/null 2>&1
fi
if [ "${KEEP_SERVER:-0}" != 1 ]; then
"${DC[@]}" down -v >/dev/null 2>&1
fi
exit "$code"
}
trap cleanup EXIT
# ---------------------------------------------------------- demo server ---
log "Starting a fresh demo Nextcloud (port $DEMO_PORT)"
"${DC[@]}" down -v >/dev/null 2>&1 || true
"${DC[@]}" up -d
log "Seeding fake data"
APP_PASSWORD="$(DEMO_BASE_URL="http://localhost:$DEMO_PORT" "$PY" tool/demo_server/seed.py | grep '^APP_PASSWORD=' | tail -n1 | cut -d= -f2-)"
[ -n "$APP_PASSWORD" ] || die "the seed script did not return an app password"
# ---------------------------------------------------------------- device ---
if [ -n "${ANDROID_SERIAL:-}" ]; then
SERIAL="$ANDROID_SERIAL"
log "Using existing device $SERIAL"
else
log "Preparing the emulator"
[ -n "$SDK_ROOT" ] || die "set ANDROID_HOME to your Android SDK"
EMULATOR="$(find_tool emulator || true)"
if [ -z "$EMULATOR" ] || ! [ -d "$SDK_ROOT/system-images/android-${API_LEVEL}/google_apis/x86_64" ]; then
log "Installing the emulator + system image (one-off, a few GB)"
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
"$SDKMANAGER" "emulator" "platform-tools" "$SYSTEM_IMAGE"
EMULATOR="$(find_tool emulator)"
fi
# avdmanager also prints a harmless "Could not load devices from
# .../devices.xml" error for system images that ship no devices.xml, so its
# output is hidden and success is judged by whether the AVD exists.
echo no | "$AVDMANAGER" create avd --force -n "$AVD_NAME" -k "$SYSTEM_IMAGE" -d pixel_6 >/dev/null 2>&1 || true
AVD_DIR="$(to_unix "${ANDROID_AVD_HOME:-$HOME/.android/avd}")/$AVD_NAME.avd"
[ -d "$AVD_DIR" ] || die "could not find the AVD at $AVD_DIR (set ANDROID_AVD_HOME)"
set_ini() { # <key> <value>: replace or append in the AVD's config.ini
if grep -q "^$1=" "$AVD_DIR/config.ini"; then
sed -i.bak "s|^$1=.*|$1=$2|" "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak"
else
echo "$1=$2" >> "$AVD_DIR/config.ini"
fi
}
sed -i.bak '/^skin\.path=/d' "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak"
set_ini skin.name "${SCREEN_W}x${SCREEN_H}"
set_ini hw.lcd.width "$SCREEN_W"
set_ini hw.lcd.height "$SCREEN_H"
set_ini hw.lcd.density "$SCREEN_DENSITY"
log "Booting the emulator"
EMULATOR_FLAGS=(-avd "$AVD_NAME" -no-snapshot -wipe-data -no-audio -no-boot-anim -gpu swiftshader_indirect)
[ "${HEADLESS:-0}" = 1 ] && EMULATOR_FLAGS+=(-no-window)
"$EMULATOR" "${EMULATOR_FLAGS[@]}" >/tmp/noo_emulator.log 2>&1 &
EMULATOR_PID=$!
STARTED_EMULATOR=1
adb wait-for-device
for _ in $(seq 1 150); do
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] && break
sleep 2
done
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] || die "the emulator did not finish booting (see /tmp/noo_emulator.log)"
SERIAL="$(adb devices | awk '/^emulator-/{print $1; exit}')"
fi
export ANDROID_SERIAL="$SERIAL"
log "Configuring the device"
adb shell input keyevent 82 >/dev/null 2>&1 || true # dismiss keyguard
adb shell svc power stayon true >/dev/null 2>&1 || true # never sleep mid-run
# Make the demo server reachable from the device as localhost. Binding a port
# below 1024 (the default 80) on the device needs root - the google_apis
# emulator images allow `adb root` - otherwise fall back to a high device
# port, which then shows up as ":8080" in the app.
DEVICE_PORT="$DEMO_PORT"
if [ "$DEMO_PORT" -lt 1024 ]; then
adb root >/dev/null 2>&1 || true
sleep 2
adb wait-for-device
fi
if ! adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" >/dev/null 2>&1; then
DEVICE_PORT=8080
printf 'note: could not bind port %s on the device (no root); using %s instead\n' "$DEMO_PORT" "$DEVICE_PORT" >&2
adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" || die "adb reverse failed"
fi
SERVER_HOST="localhost"
if [ -n "$DEMO_HOST" ]; then
# Best effort: make DEMO_HOST resolve to the (reverse-forwarded) loopback.
if adb root >/dev/null 2>&1 && sleep 2 && adb remount >/dev/null 2>&1 &&
adb shell "echo '127.0.0.1 $DEMO_HOST' >> /system/etc/hosts" >/dev/null 2>&1; then
SERVER_HOST="$DEMO_HOST"
else
printf 'warning: could not edit the device hosts file; showing "localhost" instead of %s\n' "$DEMO_HOST" >&2
fi
fi
SERVER_URL="http://$SERVER_HOST"
[ "$DEVICE_PORT" = 80 ] || SERVER_URL="$SERVER_URL:$DEVICE_PORT"
# Clean status bar: 12:00, full battery, full Wi-Fi, no notification icons.
adb shell settings put global sysui_demo_allowed 1
demo() { adb shell am broadcast -a com.android.systemui.demo -e command "$@" >/dev/null; }
demo enter
demo clock -e hhmm 1200
demo battery -e level 100 -e plugged false
demo network -e wifi show -e level 4 -e fully true -e mobile hide
demo notifications -e visible false
# --------------------------------------------------------------- capture ---
log "Capturing screenshots (this drives the app for a few minutes)"
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
SCREENSHOT_DIR="$OUT_DIR" flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/store_screenshots_test.dart \
-d "$SERIAL" \
--dart-define="DEMO_SERVER_URL=$SERVER_URL" \
--dart-define="DEMO_USERNAME=Alex" \
--dart-define="DEMO_APP_PASSWORD=$APP_PASSWORD"
log "Making them Play Store ready"
"$PY" tool/finalize_screenshots.py "$OUT_DIR"
log "Done: $OUT_DIR"
printf '\nBefore uploading, LOOK at every image for anything real: the server\n' >&2
printf 'address/username in the header and Settings, notification icons, names.\n' >&2