Author SHA1 Message Date
ayushyaandClaude Code 0b7df833f6 Tablet layout polish: tablet-only sidebar layout, floating toolbar, Settings submenus, info notes, adaptive grids; bump to 1.0.1+4
Build App Bundle / build (push) Successful in 6m0s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 17:08:22 -04:00
ayushyaandClaude Code ed747a0407 Upload release AAB to Google Play internal track from CI
Build App Bundle / build (push) Successful in 7m36s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 16:18:45 -04:00
ayushyaandClaude Code 9c9e57ec22 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>
2026-10-01 14:50:07 -04:00
ayushyaandClaude Code 8aa7015c3f Set app version to 0.0.1+2
Build App Bundle / build (push) Successful in 5m38s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 10:18:21 -04:00
ayushya 88abca647d Move Website source 2026-10-01 10:13:33 -04:00
ayushya 65b772d6e7 Merge pull request 'Set app version to 0.0.1' (#2) from noo-design-system-rebuild into main
Reviewed-on: #2
2026-10-01 09:37:12 -04:00
51 changed files with 679 additions and 2671 deletions
+3 -1
View File
@@ -275,7 +275,9 @@ bare system prompt gives no context on its own.
`MainShellView` builds its chrome from the Noo nav kit `MainShellView` builds its chrome from the Noo nav kit
(`widgets/noo/nav/`) and switches between two layouts on (`widgets/noo/nav/`) and switches between two layouts on
`NooLayout.isDesktop`: `NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
phone in landscape keeps the mobile layout and only tablet-class windows get
the sidebar):
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar` - **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
for *every* tab (previously only Files had shell-level top chrome, with for *every* tab (previously only Files had shell-level top chrome, with
+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 `isRestoringSession` still gates the splash screen until the above resolves
— see `standards.md` for why widget tests must mock both storage channels — see `standards.md` for why widget tests must mock both storage channels
rather than relying on this async path throwing naturally. 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`).
+13 -26
View File
@@ -90,32 +90,6 @@ class/method already makes obvious.
`Align`). `Align`).
- Run with `flutter test`. - 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 ## Local install/deploy
Never use `flutter install` to push a build to a test device — it always Never use `flutter install` to push a build to a test device — it always
@@ -157,3 +131,16 @@ Networking is deliberately split: `package:http` for simple JSON/XML
request-response calls, `package:dio` only where streaming/progress is request-response calls, `package:dio` only where streaming/progress is
needed (downloads). Don't introduce a third HTTP client — extend the needed (downloads). Don't introduce a third HTTP client — extend the
existing split instead. existing split instead.
## Play Store upload (CI)
`.gitea/workflows/release.yml` (on `Release-*` tags) first checks the tag
equals `Release-<pubspec version>`, builds the signed `.aab`, attaches it to a
Gitea release, then uploads it with `r0adkll/upload-google-play` to the
`internal` track as a **draft** (promote in Play Console, or edit
`track`/`status` in the workflow). Requires the `PLAY_SERVICE_ACCOUNT_JSON`
repo secret (service account with release permissions; the app's first
release must be uploaded manually). Play rejects a repeated `versionCode`, so
bump the `+N` in `pubspec.yaml` for every tag - re-tagging the same version
will fail the upload. Release notes come from
`distribution/whatsnew/whatsnew-en-US` (max 500 chars) - update per release.
+32 -6
View File
@@ -30,10 +30,12 @@ widgets. Key points:
OS-provided `ColorScheme` wins over the seed color — always thread both OS-provided `ColorScheme` wins over the seed color — always thread both
`dynamicScheme` and `useDynamicColor` through when adding a theme knob. `dynamicScheme` and `useDynamicColor` through when adding a theme knob.
- **Font**: Inter via `google_fonts`, applied through - **Font**: Inter via `google_fonts`, applied through
`GoogleFonts.interTextTheme(...)`. In tests, set `GoogleFonts.interTextTheme(...)`. Font files ship in
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without `assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
it, the font-fetch call to Google's CDN can stall `pumpAndSettle` `main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
indefinitely (see `standards.md`). 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, - **Cards**: flat (`elevation: 0`), 20px rounded corners,
`surfaceContainerLow`. `surfaceContainerLow`.
- **App bars**: flat, not centered, `surface` background. - **App bars**: flat, not centered, `surface` background.
@@ -78,10 +80,10 @@ Catalog:
| Folder | Components | | Folder | Components |
|---|---| |---|---|
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | | `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooSummaryCard`, `NooSelectionBar` | | `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` | | `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` | | `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` | | `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` (floating rounded card with margin; `NooToolbar.outerHeight` for `appBar` sizing) |
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` | | `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
Gotchas: Gotchas:
@@ -324,3 +326,27 @@ blocks are noted where they matter:
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
layer), so it renders at a sensible size at 72-80px. 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.
## Desktop/tablet layout notes
- The desktop 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.
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
pushed category screens render the desktop card layout.
- 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.
+26
View File
@@ -39,6 +39,16 @@ jobs:
# platform/build-tools versions it actually needs on its own. # platform/build-tools versions it actually needs on its own.
packages: "platform-tools" packages: "platform-tools"
- name: Check tag matches pubspec version
# Play rejects a repeated versionCode and a mismatched tag would
# publish the wrong version name, so fail fast before building.
run: |
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
if [ "${GITHUB_REF_NAME}" != "Release-${VERSION}" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match pubspec version ${VERSION}" >&2
exit 1
fi
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
@@ -67,6 +77,7 @@ jobs:
mkdir -p release mkdir -p release
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab" cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV" echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Publish Gitea release with App Bundle attached - name: Publish Gitea release with App Bundle attached
# Gitea-native release action (not GitHub's) - authenticates and # Gitea-native release action (not GitHub's) - authenticates and
@@ -77,3 +88,18 @@ jobs:
uses: akkuman/gitea-release-action@v1 uses: akkuman/gitea-release-action@v1
with: with:
files: ${{ env.AAB_PATH }} files: ${{ env.AAB_PATH }}
- name: Upload to Google Play (internal track, draft)
# Stages the build only; promote it in Play Console, or change
# `status` to "completed" (and/or `track`) to roll out automatically.
# Needs the PLAY_SERVICE_ACCOUNT_JSON repo secret, and the app's
# first release must already have been uploaded by hand.
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: dev.ayushya.noo
releaseFiles: ${{ env.AAB_PATH }}
releaseName: ${{ env.VERSION }}
track: internal
status: draft
whatsNewDirectory: distribution/whatsnew
-3
View File
@@ -45,6 +45,3 @@ app.*.map.json
/android/app/profile /android/app/profile
/android/app/release /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
View File
@@ -0,0 +1 @@
Improved tablet layout: sidebar navigation, floating title bars, and adaptive grids. Long-press selection and Settings submenus now work on tablets.
@@ -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));
}
}
+8 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -44,6 +45,8 @@ import 'widgets/shell/shell_common.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Fonts ship in assets/google_fonts; never fetch them from Google at runtime.
GoogleFonts.config.allowRuntimeFetching = false;
runApp( runApp(
MultiProvider( MultiProvider(
// Split out of the former single `ServerProvider` god object - see // Split out of the former single `ServerProvider` god object - see
@@ -441,7 +444,10 @@ class _MainShellViewState extends State<MainShellView> {
if (isDesktop) { if (isDesktop) {
scaffold = Scaffold( scaffold = Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
body: Row( // Tablets keep the system bars, so inset for them (desktop's
// insets are zero).
body: SafeArea(
child: Row(
children: [ children: [
NooSidebar( NooSidebar(
account: NooSidebarAccount( account: NooSidebarAccount(
@@ -463,7 +469,6 @@ class _MainShellViewState extends State<MainShellView> {
selected: tab == selectedTab, selected: tab == selectedTab,
onTap: () => selectTab(tab), onTap: () => selectTab(tab),
), ),
if (hiddenTabs.isNotEmpty) const NooSidebarDivider(),
for (final tab in hiddenTabs) for (final tab in hiddenTabs)
NooSidebarItem( NooSidebarItem(
icon: tab.icon, icon: tab.icon,
@@ -506,6 +511,7 @@ class _MainShellViewState extends State<MainShellView> {
), ),
], ],
), ),
),
); );
} else { } else {
final bottomBarStyle = settings.bottomBarStyle; final bottomBarStyle = settings.bottomBarStyle;
+13
View File
@@ -147,6 +147,19 @@ class NextcloudService {
return response.bodyBytes; 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 { Future<bool> testConnection() async {
try { try {
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/'; final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
+37 -64
View File
@@ -20,11 +20,8 @@ import '../widgets/settings/settings_tabs.dart';
/// order: account card, accounts, security, file sync, files cache, /// order: account card, accounts, security, file sync, files cache,
/// appearance, tabs, action bar, swipe on a file). /// appearance, tabs, action bar, swipe on a file).
/// ///
/// Desktop is unchanged: a 2-column grid of cards ([_DesktopGrid]) wide /// Every layout (including wide tablet, in a centered 640px column) is
/// enough to see every section at once, so it has no scroll-depth problem /// a two-level menu, the way native iOS/Android Settings apps
/// and needs no menu.
///
/// Mobile is a two-level menu, the way native iOS/Android Settings apps
/// work: [SettingsAccountCard] (the account summary, not a settings picker) /// work: [SettingsAccountCard] (the account summary, not a settings picker)
/// stays pinned at the top of a single top-level list ([_MobileMenu]), and /// stays pinned at the top of a single top-level list ([_MobileMenu]), and
/// every other section becomes one tappable [NooSettingsRow] - icon, title, /// every other section becomes one tappable [NooSettingsRow] - icon, title,
@@ -47,18 +44,6 @@ import '../widgets/settings/settings_tabs.dart';
class AccountView extends StatelessWidget { class AccountView extends StatelessWidget {
const AccountView({super.key}); const AccountView({super.key});
static const _sections = <Widget>[
SettingsAccountCard(),
SettingsAccountsSection(),
SettingsSecuritySection(),
SettingsFileSyncSection(),
SettingsFilesCacheSection(),
SettingsAppearanceSection(),
SettingsTabsSection(),
SettingsActionBarSection(),
SettingsSwipeSection(),
];
static final _categories = <_SettingsCategory>[ static final _categories = <_SettingsCategory>[
_SettingsCategory( _SettingsCategory(
title: 'Accounts', title: 'Accounts',
@@ -110,7 +95,7 @@ class AccountView extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
appBar: desktop appBar: desktop
? const NooToolbar(title: 'Settings') ? _desktopBar(context, 'Settings')
: NooTopBar( : NooTopBar(
style: NooLayout.navStyle(context), style: NooLayout.navStyle(context),
title: 'Settings', title: 'Settings',
@@ -119,7 +104,12 @@ class AccountView extends StatelessWidget {
body: SafeArea( body: SafeArea(
top: false, top: false,
child: desktop child: desktop
? _DesktopGrid(sections: _sections) ? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: _MobileMenu(categories: _categories),
),
)
: _MobileMenu(categories: _categories), : _MobileMenu(categories: _categories),
), ),
); );
@@ -207,16 +197,24 @@ class _SettingsCategoryScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
return Scaffold( return Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
appBar: NooTopBar( appBar: desktop
? _desktopBar(context, category.title)
: NooTopBar(
style: NooLayout.navStyle(context), style: NooLayout.navStyle(context),
title: category.title, title: category.title,
leading: const NooTopBarBack(), leading: const NooTopBarBack(),
), ),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: desktop ? 640 : double.infinity,
),
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
NooSpace.sm, NooSpace.sm,
@@ -228,57 +226,32 @@ class _SettingsCategoryScreen extends StatelessWidget {
children: [category.builder(context)], children: [category.builder(context)],
), ),
), ),
);
}
}
/// Splits the sections between two columns rather than a strict grid, since
/// each card's content height varies a lot (the tab reorder list and the
/// accounts list can both run much taller than, say, Security) - a fixed
/// grid would either clip content or leave large gaps.
class _DesktopGrid extends StatelessWidget {
final List<Widget> sections;
const _DesktopGrid({required this.sections});
@override
Widget build(BuildContext context) {
final left = <Widget>[];
final right = <Widget>[];
for (var i = 0; i < sections.length; i++) {
(i.isEven ? left : right).add(sections[i]);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(NooSpace.xl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1040),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _Column(children: left)),
const SizedBox(width: NooSpace.xl),
Expanded(child: _Column(children: right)),
],
), ),
), ),
); );
} }
} }
class _Column extends StatelessWidget { PreferredSizeWidget _desktopBar(BuildContext context, String title) {
final List<Widget> children; final top = MediaQuery.paddingOf(context).top;
const _Column({required this.children}); return PreferredSize(
preferredSize: Size.fromHeight(NooToolbar.outerHeight + top),
@override child: Padding(
Widget build(BuildContext context) { padding: EdgeInsets.only(top: top),
return Column( child: NooToolbar(
crossAxisAlignment: CrossAxisAlignment.start, titleWidget: Row(
mainAxisSize: MainAxisSize.min,
spacing: NooSpace.sm,
children: [ children: [
for (final child in children) ...[ IconButton(
child, icon: const Icon(LucideIcons.arrowLeft),
const SizedBox(height: NooSpace.xl), tooltip: 'Back',
], onPressed: () => Navigator.maybePop(context),
),
Text(title),
], ],
),
),
),
); );
} }
}
+3 -1
View File
@@ -586,7 +586,9 @@ class _FavoritesViewState extends State<FavoritesView> {
), ),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isDesktop ? 5 : 2, crossAxisCount: isDesktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
crossAxisSpacing: isDesktop ? 16 : 10, crossAxisSpacing: isDesktop ? 16 : 10,
mainAxisSpacing: isDesktop ? 16 : 10, mainAxisSpacing: isDesktop ? 16 : 10,
childAspectRatio: isDesktop ? 0.92 : 0.85, childAspectRatio: isDesktop ? 0.92 : 0.85,
+6 -1
View File
@@ -754,7 +754,9 @@ class _FilesViewState extends State<FilesView> {
), ),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isDesktop ? 5 : 2, crossAxisCount: isDesktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
childAspectRatio: isDesktop ? 1.05 : 0.92, childAspectRatio: isDesktop ? 1.05 : 0.92,
crossAxisSpacing: isDesktop ? 16 : 10, crossAxisSpacing: isDesktop ? 16 : 10,
mainAxisSpacing: isDesktop ? 16 : 10, mainAxisSpacing: isDesktop ? 16 : 10,
@@ -1323,6 +1325,9 @@ class _FilesViewState extends State<FilesView> {
selected: isSelected, selected: isSelected,
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop), thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop),
onTap: () => _handleItemTap(context, item, picking: picking), onTap: () => _handleItemTap(context, item, picking: picking),
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
? null
: () => _toggleSelection(item),
onMore: !_isSelecting && !picking onMore: !_isSelecting && !picking
? () => _showItemActions(context, item) ? () => _showItemActions(context, item)
: null, : null,
+3 -1
View File
@@ -284,7 +284,9 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
padding: EdgeInsets.symmetric(horizontal: gutter), padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: desktop ? 5 : 2, crossAxisCount: desktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
childAspectRatio: desktop ? 1.05 : 0.92, childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10, crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10, mainAxisSpacing: desktop ? 16 : 10,
+5 -1
View File
@@ -342,7 +342,11 @@ class _PhotosViewState extends State<PhotosView> {
itemCount: group.items.length, itemCount: group.items.length,
columns: isDesktop columns: isDesktop
? NooPhotoGrid.desktopColumns ? NooPhotoGrid.desktopColumns
: NooPhotoGrid.mobileColumns, : NooLayout.gridColumns(
context,
phone: NooPhotoGrid.mobileColumns,
minTile: 130,
),
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap, gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isDesktop ? NooLayout.gutter(context) : 0, horizontal: isDesktop ? NooLayout.gutter(context) : 0,
+3 -1
View File
@@ -209,7 +209,9 @@ class _ShareUploadViewState extends State<ShareUploadView> {
padding: EdgeInsets.symmetric(horizontal: gutter), padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: desktop ? 5 : 2, crossAxisCount: desktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
childAspectRatio: desktop ? 1.05 : 0.92, childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10, crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10, mainAxisSpacing: desktop ? 16 : 10,
@@ -154,6 +154,7 @@ class NooFileTableRow extends StatelessWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onDoubleTap; final VoidCallback? onDoubleTap;
final VoidCallback? onSecondaryTap; final VoidCallback? onSecondaryTap;
final VoidCallback? onLongPress;
/// Overflow menu tap. The button is hidden when null. /// Overflow menu tap. The button is hidden when null.
final VoidCallback? onMore; final VoidCallback? onMore;
@@ -171,6 +172,7 @@ class NooFileTableRow extends StatelessWidget {
this.onTap, this.onTap,
this.onDoubleTap, this.onDoubleTap,
this.onSecondaryTap, this.onSecondaryTap,
this.onLongPress,
this.onMore, this.onMore,
}); });
@@ -193,6 +195,7 @@ class NooFileTableRow extends StatelessWidget {
onTap: onTap, onTap: onTap,
onDoubleTap: onDoubleTap, onDoubleTap: onDoubleTap,
onSecondaryTap: onSecondaryTap, onSecondaryTap: onSecondaryTap,
onLongPress: onLongPress,
child: SizedBox( child: SizedBox(
height: NooSizes.rowDesktop, height: NooSizes.rowDesktop,
child: _TableColumns( child: _TableColumns(
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Tinted info callout (info-soft fill, info icon) for a short notice that
/// must be noticed immediately, unlike a muted footer/subtitle line.
class NooInfoNote extends StatelessWidget {
final String message;
const NooInfoNote({super.key, required this.message});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: colors.infoSoft,
borderRadius: BorderRadius.circular(NooRadii.input),
border: Border.all(color: colors.info.withValues(alpha: 0.35)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(LucideIcons.info, size: 18, color: colors.info),
),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: NooText.body.copyWith(color: colors.fg1, height: 1.35),
),
),
],
),
);
}
}
+15 -5
View File
@@ -48,21 +48,30 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
); );
@override @override
Size get preferredSize => const Size.fromHeight(NooSizes.toolbar); Size get preferredSize => const Size.fromHeight(outerHeight);
static const _margin = EdgeInsets.fromLTRB(12, 8, 12, 4);
/// Bar height plus the margin that makes it float.
static const double outerHeight = NooSizes.toolbar + 12;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1); final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1);
return Material( return Padding(
padding: _margin,
child: Material(
color: backgroundColor ?? colors.surface, color: backgroundColor ?? colors.surface,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NooRadii.card),
side: BorderSide(color: colors.line),
),
child: Container( child: Container(
height: NooSizes.toolbar, height: NooSizes.toolbar,
padding: const EdgeInsetsDirectional.only(start: 24, end: 20), padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: colors.line)),
),
child: Row( child: Row(
spacing: 12, spacing: 12,
children: [ children: [
@@ -102,6 +111,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
], ],
), ),
), ),
),
); );
} }
} }
+1
View File
@@ -206,6 +206,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
style: NooText.title.copyWith(height: 1, color: colors.fg1), style: NooText.title.copyWith(height: 1, color: colors.fg1),
), ),
), ),
const SizedBox(width: NooSpace.md),
Expanded(child: androidTitleTrailing!), Expanded(child: androidTitleTrailing!),
] else ] else
Expanded( Expanded(
+22 -2
View File
@@ -11,8 +11,16 @@ class NooLayout {
/// (table rows, 5-column grids, 8-column photos, 24px gutter). /// (table rows, 5-column grids, 8-column photos, 24px gutter).
static const double desktopBreakpoint = 900; static const double desktopBreakpoint = 900;
static bool isDesktop(BuildContext context) => /// Shortest window side that counts as a tablet-class window (the usual
MediaQuery.sizeOf(context).width >= desktopBreakpoint; /// 600dp rule). A phone in landscape is wide but short, so it stays on the
/// mobile layout.
static const double tabletShortestSide = 600;
static bool isDesktop(BuildContext context) {
final size = MediaQuery.sizeOf(context);
return size.width >= desktopBreakpoint &&
size.shortestSide >= tabletShortestSide;
}
static NooNavStyle navStyle(BuildContext context) => static NooNavStyle navStyle(BuildContext context) =>
NooNavStyle.fromPlatform(Theme.of(context).platform); NooNavStyle.fromPlatform(Theme.of(context).platform);
@@ -24,4 +32,16 @@ class NooLayout {
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop. /// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12; 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).
static int gridColumns(
BuildContext context, {
required int phone,
required double minTile,
}) {
final width = MediaQuery.sizeOf(context).width;
return (width / minTile).floor().clamp(phone, 12);
}
} }
@@ -64,6 +64,8 @@ class NooPersonAccessRow extends StatelessWidget {
final String permission; final String permission;
final VoidCallback? onPermissionTap; final VoidCallback? onPermissionTap;
final String ownerLabel; final String ownerLabel;
/// Replaces the owner label / permission pill (e.g. a plain search result).
final Widget? trailing;
const NooPersonAccessRow({ const NooPersonAccessRow({
super.key, super.key,
@@ -74,6 +76,7 @@ class NooPersonAccessRow extends StatelessWidget {
this.permission = 'Can edit', this.permission = 'Can edit',
this.onPermissionTap, this.onPermissionTap,
this.ownerLabel = 'Owner', this.ownerLabel = 'Owner',
this.trailing,
}); });
@override @override
@@ -113,7 +116,9 @@ class NooPersonAccessRow extends StatelessWidget {
), ),
), ),
const SizedBox(width: NooSpace.sm), const SizedBox(width: NooSpace.sm),
if (owner) if (trailing != null)
trailing!
else if (owner)
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3)) Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
else else
NooPermissionPill(label: permission, onTap: onPermissionTap), NooPermissionPill(label: permission, onTap: onPermissionTap),
+13 -2
View File
@@ -106,13 +106,23 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
@override @override
Widget build(BuildContext context) { 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( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
if (!_needsPeek) { if (!_needsPeek) {
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction, maxHeight: keyboardUp
? constraints.maxHeight
: constraints.maxHeight * _kNooSheetPeekFraction,
), ),
child: _buildScrollable(null), child: _buildScrollable(null),
); );
@@ -141,6 +151,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
); );
}, },
), ),
),
); );
} }
} }
@@ -48,6 +48,8 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [ children: [
SettingsSection( SettingsSection(
title: 'Appearance', title: 'Appearance',
notice:
'Bottom bar style, menu style and search in bottom bar have no effect on wide tablets, which use a sidebar.',
children: [ children: [
_ThemeRow(settings: settings), _ThemeRow(settings: settings),
_BottomBarStyleRow(settings: settings), _BottomBarStyleRow(settings: settings),
+19 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../noo/lists/noo_grouped_list.dart'; import '../noo/lists/noo_grouped_list.dart';
import '../noo/lists/noo_info_note.dart';
import '../noo/noo_layout.dart'; import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart'; import '../noo/overlays/noo_sheet.dart';
@@ -17,10 +18,14 @@ class SettingsSection extends StatelessWidget {
final String? subtitle; final String? subtitle;
final List<Widget> children; final List<Widget> children;
/// Shown as a prominent [NooInfoNote] above the rows.
final String? notice;
const SettingsSection({ const SettingsSection({
super.key, super.key,
required this.title, required this.title,
this.subtitle, this.subtitle,
this.notice,
required this.children, required this.children,
}); });
@@ -28,7 +33,7 @@ class SettingsSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
if (!NooLayout.isDesktop(context)) { if (!NooLayout.isDesktop(context)) {
return NooGroupedList( final list = NooGroupedList(
label: title, label: title,
footer: subtitle != null ? Text(subtitle!) : null, footer: subtitle != null ? Text(subtitle!) : null,
// Not collapsible: each mobile Settings section now renders on its // Not collapsible: each mobile Settings section now renders on its
@@ -40,6 +45,15 @@ class SettingsSection extends StatelessWidget {
// one is already isolated on its own screen. // one is already isolated on its own screen.
children: children, children: children,
); );
if (notice == null) return list;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooInfoNote(message: notice!),
const SizedBox(height: NooSpace.md),
list,
],
);
} }
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -57,6 +71,10 @@ class SettingsSection extends StatelessWidget {
Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)), Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)),
], ],
const SizedBox(height: NooSpace.md), const SizedBox(height: NooSpace.md),
if (notice != null) ...[
NooInfoNote(message: notice!),
const SizedBox(height: NooSpace.md),
],
FlatRowGroup(children: children), FlatRowGroup(children: children),
], ],
), ),
+6 -1
View File
@@ -37,7 +37,11 @@ class SettingsSwipeSection extends StatelessWidget {
NooSettingsRow( NooSettingsRow(
label: Text(_swipeActionLabel(action)), label: Text(_swipeActionLabel(action)),
trailing: action == current trailing: action == current
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) ? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null, : null,
onTap: () { onTap: () {
onChanged(action); onChanged(action);
@@ -55,6 +59,7 @@ class SettingsSwipeSection extends StatelessWidget {
return SettingsSection( return SettingsSection(
title: 'Swipe on a file', title: 'Swipe on a file',
subtitle: 'Choose what swiping a file left or right does in list view', subtitle: 'Choose what swiping a file left or right does in list view',
notice: 'Swipe actions have no effect on wide tablets.',
children: [ children: [
NooSettingsRow( NooSettingsRow(
icon: LucideIcons.chevronsRight, icon: LucideIcons.chevronsRight,
+77 -14
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -71,11 +72,13 @@ class ShareSheet extends StatefulWidget {
return showNooSheet( return showNooSheet(
context, context,
children: [ children: [
NooOverlayHeader( Builder(
builder: (sheetContext) => NooOverlayHeader(
leading: detailsFileTile(item), leading: detailsFileTile(item),
title: item.name, title: item.name,
subtitle: detailsMetaLine(item), subtitle: detailsMetaLine(item),
onClose: () => Navigator.pop(context), onClose: () => Navigator.of(sheetContext).pop(),
),
), ),
ShareSheet(item: item), ShareSheet(item: item),
], ],
@@ -99,9 +102,36 @@ class _ShareSheetState extends State<ShareSheet> {
bool _showInherited = false; bool _showInherited = false;
final _peopleController = TextEditingController(); final _peopleController = TextEditingController();
final _linkController = 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 @override
void dispose() { void dispose() {
_peopleFocus.dispose();
_peopleController.dispose(); _peopleController.dispose();
_linkController.dispose(); _linkController.dispose();
super.dispose(); super.dispose();
@@ -128,6 +158,7 @@ class _ShareSheetState extends State<ShareSheet> {
return; return;
} }
setState(() => _isSearching = true); setState(() => _isSearching = true);
_revealPeopleSection();
final ops = context.read<ItemOperations>(); final ops = context.read<ItemOperations>();
final results = await ops.searchSharees(trimmed); final results = await ops.searchSharees(trimmed);
if (!mounted) return; if (!mounted) return;
@@ -135,6 +166,7 @@ class _ShareSheetState extends State<ShareSheet> {
_searchResults = results; _searchResults = results;
_isSearching = false; _isSearching = false;
}); });
_revealPeopleSection();
} }
/// The single "Name, email or group" field: reuses whichever match is /// The single "Name, email or group" field: reuses whichever match is
@@ -364,6 +396,7 @@ class _ShareSheetState extends State<ShareSheet> {
absorbing: _isAddingPerson, absorbing: _isAddingPerson,
child: NooTextField( child: NooTextField(
controller: _peopleController, controller: _peopleController,
focusNode: _peopleFocus,
leadingIcon: LucideIcons.search, leadingIcon: LucideIcons.search,
placeholder: 'Name, email or group', placeholder: 'Name, email or group',
onChanged: _search, onChanged: _search,
@@ -381,17 +414,34 @@ class _ShareSheetState extends State<ShareSheet> {
), ),
), ),
for (final sharee in _searchResults) 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 icon: sharee.type == ShareeType.group
? LucideIcons.users ? LucideIcons.users
: LucideIcons.user, : null,
label: Text(sharee.label),
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
onTap: () => _addSharee(sharee),
), ),
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), const SizedBox(height: NooSpace.xs),
NooPersonAccessRow( NooPersonAccessRow(
avatar: NooAvatar(initials: _initial(session.username), current: true), avatar: NooAvatar(
initials: _initial(session.username),
current: true,
),
name: session.username, name: session.username,
owner: true, owner: true,
), ),
@@ -402,12 +452,16 @@ class _ShareSheetState extends State<ShareSheet> {
color: NooPhotoTile.paletteColor( color: NooPhotoTile.paletteColor(
(share.sharedWithDisplayName ?? share.id).hashCode, (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', name: share.sharedWithDisplayName ?? 'Shared',
subtitle: share.shareType == ShareType.group subtitle: share.shareType == ShareType.group
? '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', permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
onPermissionTap: () => _openPersonMenu(share), onPermissionTap: () => _openPersonMenu(share),
), ),
@@ -429,7 +483,9 @@ class _ShareSheetState extends State<ShareSheet> {
), ),
), ),
Icon( Icon(
_showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown, _showInherited
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 16, size: 16,
color: colors.fg3, color: colors.fg3,
), ),
@@ -464,7 +520,10 @@ class _ShareSheetState extends State<ShareSheet> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)), Text(
'Internal link',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
Text( Text(
'For people who already have access', 'For people who already have access',
style: NooText.meta.copyWith(color: colors.fg3), style: NooText.meta.copyWith(color: colors.fg3),
@@ -559,6 +618,7 @@ class _ShareSheetState extends State<ShareSheet> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
NooShareSection( NooShareSection(
key: _peopleKey,
title: 'Share with people', title: 'Share with people',
child: _peopleSection(colors, session, peopleShares, internalLink), child: _peopleSection(colors, session, peopleShares, internalLink),
), ),
@@ -572,13 +632,16 @@ class _ShareSheetState extends State<ShareSheet> {
caption: hasLink caption: hasLink
? null ? null
: 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.', : '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) ...[ if (!widget.item.isFolder) ...[
const SizedBox(height: 22), const SizedBox(height: 22),
NooShareSection( NooShareSection(
title: 'Send file directly', 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( child: NooButton(
variant: NooButtonVariant.outline, variant: NooButtonVariant.outline,
size: NooButtonSize.card, size: NooButtonSize.card,
+154 -10
View File
@@ -1,12 +1,25 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; 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 '../../models/nextcloud_item.dart';
import '../../providers/session_controller.dart'; import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../noo/core/noo_button.dart';
/// Plain monospace text-file preview for [FileViewerScreen]'s static (non- /// Space the viewer's floating top bar and bottom action bar cover, so the
/// swipeable) preview path (`_textPreviewExtensions`). /// 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 { class MediaTextPreview extends StatefulWidget {
final NextcloudItem item; final NextcloudItem item;
final SessionController session; final SessionController session;
@@ -28,15 +41,35 @@ class MediaTextPreview extends StatefulWidget {
} }
class _MediaTextPreviewState extends State<MediaTextPreview> { class _MediaTextPreviewState extends State<MediaTextPreview> {
String? _content; final _controller = TextEditingController();
String? _saved;
String? _error; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
_controller.addListener(() => setState(() {}));
_load(); _load();
} }
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _load() async { Future<void> _load() async {
try { try {
final List<int> bytes; final List<int> bytes;
@@ -53,13 +86,39 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
bytes = await widget.session.service!.fetchBytes(widget.item.path); bytes = await widget.session.service!.fetchBytes(widget.item.path);
} }
if (mounted) { if (mounted) {
setState(() => _content = utf8.decode(bytes, allowMalformed: true)); final text = utf8.decode(bytes, allowMalformed: true);
_controller.text = text;
setState(() => _saved = text);
} }
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = e.toString()); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; 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)); 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( return Container(
color: colors.surface, color: colors.surface,
padding: const EdgeInsets.all(20), child: Stack(
child: SingleChildScrollView( children: [
child: SelectableText( Positioned.fill(
_content!, child: _showRendered
style: NooText.mono.copyWith(color: colors.fg1, height: 1.5), ? 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 description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -267,6 +262,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -333,11 +336,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
google_fonts: google_fonts:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -378,11 +376,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -503,6 +496,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.20" version: "3.1.20"
markdown:
dependency: transitive
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.dev"
source: hosted
version: "7.3.1"
marquee: marquee:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -791,14 +792,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -932,14 +925,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" 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: synchronized:
dependency: transitive dependency: transitive
description: description:
@@ -1084,14 +1069,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
webview_flutter: webview_flutter:
dependency: "direct main" dependency: "direct main"
description: 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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 0.0.1+1 version: 1.0.1+4
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
@@ -58,16 +58,12 @@ dependencies:
webview_flutter: ^4.14.1 webview_flutter: ^4.14.1
permission_handler: ^11.3.1 permission_handler: ^11.3.1
connectivity_plus: ^6.1.5 connectivity_plus: ^6.1.5
flutter_markdown_plus: ^1.0.12
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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 # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # 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 # activated in the `analysis_options.yaml` file located at the root of your
@@ -89,6 +85,7 @@ flutter:
assets: assets:
- assets/icon/app_icon.png - assets/icon/app_icon.png
- assets/icon/app_icon_monochrome.png - assets/icon/app_icon_monochrome.png
- assets/google_fonts/
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # 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
-38
View File
@@ -1,38 +0,0 @@
# Noo website
The product site for Noo, meant to be deployed at `noo.ayushya.dev`.
Static HTML/CSS/vanilla JS, no build step and no dependencies beyond the
two Google Fonts already used by the app (Schibsted Grotesk, Instrument
Sans). Colors, radii, type scale and motion timing are copied from
`lib/theme/design_tokens.dart` so the site and the app read as the same
product.
## Files
- `index.html` — the whole page (hero, features, customize, privacy, CTA).
- `styles.css` — all styling, incl. the design tokens as CSS custom
properties and a dark-mode variant (`prefers-color-scheme` and an
explicit `data-theme` override for a future toggle).
- `script.js` — mobile nav, the hero's exploded-layer parallax
(scroll + pointer), the two interactive Customize demos (accent color,
bottom bar style), and scroll-reveal.
- `favicon.svg`.
## Local preview
Any static file server works, e.g.:
```bash
cd website
python3 -m http.server 8000
```
Then open `http://localhost:8000`.
## Deploying
Point any static host (Cloudflare Pages, Netlify, GitHub Pages, or a
plain nginx `root`) at this folder - there's nothing to build. Update the
`https://github.com` placeholder links and the two "Download for..."
buttons in `index.html` once real release links exist.
-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="9" fill="#8D0DE3"/>
<rect x="9" y="9" width="14" height="14" rx="5" fill="#FFFFFF" fill-opacity="0.92"/>
</svg>

Before

Width:  |  Height:  |  Size: 210 B

-307
View File
@@ -1,307 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noo — a Nextcloud client that feels like it belongs on your phone</title>
<meta name="description" content="Noo is a fast, private, deeply customizable Nextcloud client for Android and iOS. Browse files and photos, sync for offline, and make it look like yours.">
<meta property="og:title" content="Noo — a Nextcloud client that feels like it belongs on your phone">
<meta property="og:description" content="Fast, private, and deeply customizable. Browse files and photos, sync for offline, and make Noo look like yours.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://noo.ayushya.dev">
<meta name="theme-color" content="#F3EFE6">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<div class="wrap header-row">
<a class="wordmark" href="#top">
<span class="wordmark-mark" aria-hidden="true"></span>
noo
</a>
<nav class="site-nav" aria-label="Primary">
<a href="#features">Features</a>
<a href="#customize">Customize</a>
<a href="#privacy">Privacy</a>
</nav>
<a class="btn btn-primary btn-sm" href="#get">Get Noo</a>
<button class="nav-toggle" id="navToggle" aria-label="Open menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
<nav class="mobile-nav" id="mobileNav" aria-label="Primary">
<a href="#features">Features</a>
<a href="#customize">Customize</a>
<a href="#privacy">Privacy</a>
<a class="btn btn-primary btn-sm" href="#get">Get Noo</a>
</nav>
</header>
<main id="main">
<!-- ============ HERO ============ -->
<section class="hero" id="top">
<div class="wrap hero-grid">
<div class="hero-copy">
<p class="eyebrow">For Nextcloud, unofficial</p>
<h1>Your cloud.<br>Looking like <em>yours</em>.</h1>
<p class="hero-sub">
Noo is a fast, private Nextcloud client that browses files and
photos the way a native app should — and lets you rebuild its
whole shell around how you actually use your phone.
</p>
<div class="hero-actions">
<a class="btn btn-primary btn-lg" href="#get">Get Noo</a>
<a class="btn btn-outline btn-lg" href="https://github.com" target="_blank" rel="noopener">View source</a>
</div>
<ul class="hero-proof">
<li>No password stored — Login Flow v2</li>
<li>Offline device sync</li>
<li>Open source</li>
</ul>
</div>
<div class="hero-visual" id="heroVisual" aria-hidden="true">
<div class="glow"></div>
<div class="phone" data-depth="0.15">
<div class="phone-notch"></div>
<div class="phone-screen">
<div class="mini-topbar">
<span class="mini-title">Files</span>
<span class="mini-dot"></span>
</div>
<div class="mini-list">
<div class="mini-row"><span class="mini-ic ic-folder"></span><span class="mini-lines"><b></b><i></i></span></div>
<div class="mini-row"><span class="mini-ic ic-image"></span><span class="mini-lines"><b></b><i></i></span></div>
<div class="mini-row mini-row-active"><span class="mini-ic ic-file"></span><span class="mini-lines"><b></b><i></i></span></div>
<div class="mini-row"><span class="mini-ic ic-folder"></span><span class="mini-lines"><b></b><i></i></span></div>
</div>
<div class="mini-bar">
<span class="mini-bar-pill"></span>
<span class="mini-bar-dot"></span>
<span class="mini-bar-dot"></span>
<span class="mini-bar-dot"></span>
<span class="mini-bar-dot"></span>
</div>
</div>
</div>
<div class="chip chip-photo" data-depth="0.55">
<div class="chip-photo-grid">
<span></span><span></span><span></span><span></span>
</div>
</div>
<div class="chip chip-row" data-depth="0.4">
<span class="mini-ic ic-share"></span>
<span class="mini-lines"><b></b><i></i></span>
</div>
<div class="chip chip-swatches" data-depth="0.7">
<span class="swatch" style="--c:#8D0DE3"></span>
<span class="swatch" style="--c:#2940A8"></span>
<span class="swatch" style="--c:#3E7A23"></span>
<span class="swatch" style="--c:#8C5A0E"></span>
</div>
<div class="chip chip-toggle" data-depth="0.5">
<span class="mini-lines mini-lines-toggle"><b></b><i></i></span>
<span class="mini-toggle mini-toggle-on"><i></i></span>
</div>
<div class="chip chip-badge" data-depth="0.85">
<span class="mini-ic ic-lock"></span>
Offline ready
</div>
</div>
</div>
</section>
<!-- ============ FEATURES ============ -->
<section class="section" id="features">
<div class="wrap">
<p class="eyebrow">Features</p>
<h2>Everything your Nextcloud does. Nothing it doesn't.</h2>
<p class="section-sub">
Files, photos, sharing and sync — built as a real mobile app, not a
browser tab pinned to your home screen.
</p>
<div class="grid grid-features">
<article class="card">
<span class="card-ic ic-folder-lg"></span>
<h3>Files, browsed properly</h3>
<p>Grid or list, sorted your way, with the same filters and
controls whether you're picking a file, uploading, or moving
one into place.</p>
</article>
<article class="card">
<span class="card-ic ic-image-lg"></span>
<h3>Photos, grouped by month</h3>
<p>Every image and video across your account in one gallery —
filter to just photos, just videos, or just favorites.</p>
</article>
<article class="card">
<span class="card-ic ic-download-lg"></span>
<h3>Offline, for real</h3>
<p>Pick folders to mirror locally and keep browsing — reading,
opening, previewing — with no connection at all.</p>
</article>
<article class="card">
<span class="card-ic ic-share-lg"></span>
<h3>Sharing, without the detour</h3>
<p>Share with people or a public link, see who has access, and
copy that link — all from one sheet, not three menus.</p>
</article>
<article class="card">
<span class="card-ic ic-clock-lg"></span>
<h3>Activity &amp; recent</h3>
<p>What changed, and where — grouped into today, yesterday and
this week, so catching up takes seconds.</p>
</article>
<article class="card">
<span class="card-ic ic-users-lg"></span>
<h3>Multiple accounts</h3>
<p>Switch between personal and work Nextcloud instances with a
swipe — each with its own sync, cache and lock.</p>
</article>
</div>
</div>
</section>
<!-- ============ CUSTOMIZE ============ -->
<section class="section section-alt" id="customize">
<div class="wrap">
<p class="eyebrow">Customize</p>
<h2>Shape it around your thumb, not the other way round.</h2>
<p class="section-sub">
Every screen bends to a handful of real settings — try a few
right here.
</p>
<div class="customize-grid">
<div class="customize-demo" id="accentDemo">
<div class="demo-preview">
<div class="demo-topbar">
<span class="demo-dot" style="background:var(--demo-accent)"></span>
<span class="demo-line" style="width:38%"></span>
</div>
<div class="demo-pill" style="background:var(--surface3); background:color-mix(in srgb, var(--demo-accent) 16%, transparent); color:var(--demo-accent)">Active tab</div>
<div class="demo-row"><span class="demo-line" style="width:60%"></span></div>
<div class="demo-row"><span class="demo-line" style="width:44%"></span></div>
</div>
<div class="demo-controls">
<h3>Pick your accent</h3>
<p>Six built-in colors, or match your wallpaper with dynamic
color on Android.</p>
<div class="swatch-row" role="group" aria-label="Accent color">
<button class="swatch-btn is-active" style="--c:#8D0DE3" data-accent="#8D0DE3" aria-label="Violet"></button>
<button class="swatch-btn" style="--c:#2940A8" data-accent="#2940A8" aria-label="Blue"></button>
<button class="swatch-btn" style="--c:#3E7A23" data-accent="#3E7A23" aria-label="Green"></button>
<button class="swatch-btn" style="--c:#8C5A0E" data-accent="#8C5A0E" aria-label="Amber"></button>
<button class="swatch-btn" style="--c:#A8202A" data-accent="#A8202A" aria-label="Red"></button>
<button class="swatch-btn" style="--c:#0E0D0B" data-accent="#0E0D0B" aria-label="Wallpaper"></button>
</div>
</div>
</div>
<div class="customize-demo" id="barDemo">
<div class="demo-preview demo-preview-bar">
<div class="demo-screen-fill"></div>
<div class="demo-bar" id="demoBar">
<span class="demo-bar-item is-on"></span>
<span class="demo-bar-item"></span>
<span class="demo-bar-item"></span>
<span class="demo-bar-item"></span>
</div>
</div>
<div class="demo-controls">
<h3>Floating or attached</h3>
<p>The bottom bar can sit flush with the edge, or float free
with rounded corners — your call.</p>
<div class="segmented" role="group" aria-label="Bottom bar style">
<button class="segmented-btn" data-bar="attached">Attached</button>
<button class="segmented-btn is-active" data-bar="floating">Floating</button>
</div>
</div>
</div>
<div class="customize-list">
<h3>And the rest</h3>
<ul class="feature-list">
<li>Reorder tabs, hide the ones you never open</li>
<li>Move Search into the bottom bar</li>
<li>Light, dark, or match the system</li>
<li>Pure black backgrounds in dark mode</li>
<li>Choose what swiping a file left or right does</li>
<li>Reorder the bulk-action bar for multi-select</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ============ PRIVACY ============ -->
<section class="section" id="privacy">
<div class="wrap privacy-grid">
<div>
<p class="eyebrow">Privacy</p>
<h2>It's your server. Noo just visits.</h2>
<p class="section-sub">
Noo never asks for your password. Signing in hands off to your
Nextcloud server's own login page (Login Flow v2), which returns
a revocable app token — not your credentials. Nothing but that
token, and whatever you choose to sync, ever leaves your device.
</p>
</div>
<ul class="privacy-points">
<li><span class="pt-ic ic-lock"></span> No password stored on device</li>
<li><span class="pt-ic ic-server"></span> Talks only to your own server</li>
<li><span class="pt-ic ic-code"></span> Open source, auditable</li>
<li><span class="pt-ic ic-off"></span> No analytics, no trackers</li>
</ul>
</div>
</section>
<!-- ============ GET ============ -->
<section class="section section-cta" id="get">
<div class="wrap cta-inner">
<h2>Bring your Nextcloud with you.</h2>
<p class="section-sub">Free and open source. Point it at your server and go.</p>
<div class="hero-actions cta-actions">
<a class="btn btn-primary btn-lg" href="#">Download for Android</a>
<a class="btn btn-outline btn-lg" href="#">Download for iOS</a>
</div>
<p class="cta-fine">Or <a href="https://github.com" target="_blank" rel="noopener">build it from source</a> — it's just Flutter.</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap footer-row">
<a class="wordmark wordmark-sm" href="#top">
<span class="wordmark-mark" aria-hidden="true"></span>
noo
</a>
<p class="footer-note">Noo is an independent, unofficial Nextcloud client. Nextcloud is a trademark of Nextcloud GmbH.</p>
<nav class="footer-links" aria-label="Footer">
<a href="https://github.com" target="_blank" rel="noopener">GitHub</a>
<a href="#privacy">Privacy</a>
</nav>
</div>
</footer>
<script src="script.js"></script>
</body>
</html>
-141
View File
@@ -1,141 +0,0 @@
(() => {
"use strict";
/* ---------- mobile nav ---------- */
const navToggle = document.getElementById("navToggle");
const mobileNav = document.getElementById("mobileNav");
if (navToggle && mobileNav) {
navToggle.addEventListener("click", () => {
const open = mobileNav.classList.toggle("is-open");
navToggle.setAttribute("aria-expanded", String(open));
navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
});
mobileNav.querySelectorAll("a").forEach((a) =>
a.addEventListener("click", () => {
mobileNav.classList.remove("is-open");
navToggle.setAttribute("aria-expanded", "false");
})
);
}
/* ---------- exploded hero parallax ---------- */
/* Each [data-depth] layer drifts at its own rate on scroll, and tilts
gently toward the pointer - the "exploded" pieces read as separate
depths rather than a flat collage. Skipped entirely under
prefers-reduced-motion. */
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const heroVisual = document.getElementById("heroVisual");
if (heroVisual && !reduceMotion) {
const layers = Array.from(heroVisual.querySelectorAll("[data-depth]")).map((el) => ({
el,
depth: parseFloat(el.dataset.depth) || 0.3,
}));
let scrollOffset = 0;
let pointer = { x: 0, y: 0 }; // -1..1
let ticking = false;
const apply = () => {
const rect = heroVisual.getBoundingClientRect();
const inView = rect.bottom > 0 && rect.top < window.innerHeight;
if (inView) {
for (const layer of layers) {
const scrollShift = scrollOffset * layer.depth * 40;
const px = pointer.x * layer.depth * 14;
const py = pointer.y * layer.depth * 14;
layer.el.style.transform =
`translate3d(${px}px, ${scrollShift + py}px, 0)`;
}
}
ticking = false;
};
const requestTick = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(apply);
}
};
const onScroll = () => {
const rect = heroVisual.getBoundingClientRect();
// -1 (scrolled well past) .. 1 (below viewport) - 0 while centered.
scrollOffset = (window.innerHeight / 2 - (rect.top + rect.height / 2)) / window.innerHeight;
requestTick();
};
const onPointerMove = (e) => {
const rect = heroVisual.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
requestTick();
};
const onPointerLeave = () => {
pointer = { x: 0, y: 0 };
requestTick();
};
window.addEventListener("scroll", onScroll, { passive: true });
heroVisual.addEventListener("pointermove", onPointerMove);
heroVisual.addEventListener("pointerleave", onPointerLeave);
onScroll();
}
/* ---------- accent color demo ---------- */
const accentDemo = document.getElementById("accentDemo");
if (accentDemo) {
const buttons = accentDemo.querySelectorAll(".swatch-btn");
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
buttons.forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
const color = btn.dataset.accent;
accentDemo.style.setProperty("--demo-accent", color);
});
});
}
/* ---------- bottom bar style demo ---------- */
const barDemo = document.getElementById("barDemo");
const demoBar = document.getElementById("demoBar");
if (barDemo && demoBar) {
const buttons = barDemo.querySelectorAll(".segmented-btn");
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
buttons.forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
demoBar.classList.toggle("is-attached", btn.dataset.bar === "attached");
});
});
}
/* ---------- scroll reveal ---------- */
const revealTargets = document.querySelectorAll(
".card, .customize-demo, .customize-list, .privacy-points li"
);
revealTargets.forEach((el) => el.classList.add("reveal"));
if ("IntersectionObserver" in window && !reduceMotion) {
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
io.unobserve(entry.target);
}
}
},
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" }
);
revealTargets.forEach((el) => io.observe(el));
} else {
revealTargets.forEach((el) => el.classList.add("is-visible"));
}
})();
-758
View File
@@ -1,758 +0,0 @@
/* Noo marketing site — tokens mirror lib/theme/design_tokens.dart exactly,
so this reads as the same product, not a separate marketing skin. */
:root {
--bg: #F3EFE6;
--surface: #FFFFFF;
--surface2: #F6F5F2;
--surface3: #EAE8E2;
--line: #EAE8E2;
--fg1: #0E0D0B;
--fg2: #5A5852;
--fg3: #7C7A72;
--accent: #8D0DE3;
--accent-text: #8D0DE3;
--accent-soft: #ECDFFF;
--danger: #A8202A;
--demo-accent: #8D0DE3;
--font-display: 'Schibsted Grotesk', system-ui, sans-serif;
--font-body: 'Instrument Sans', system-ui, sans-serif;
--ease: cubic-bezier(0.7, 0, 0.15, 1);
--motion-fast: 180ms;
--motion-base: 280ms;
--radius-pill: 999px;
--radius-card: 20px;
--radius-input: 14px;
--radius-panel: 28px;
--gutter: clamp(20px, 5vw, 64px);
--content-max: 1120px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0E0D0B;
--surface: #25241F;
--surface2: #3D3C38;
--surface3: #5A5852;
--line: #3D3C38;
--fg1: #F3EFE6;
--fg2: #BDBAB1;
--fg3: #9E9B92;
--accent: #8D0DE3;
--accent-text: #CBA5FD;
--accent-soft: #2F0454;
--danger: #F49E9E;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0E0D0B;
--surface: #25241F;
--surface2: #3D3C38;
--surface3: #5A5852;
--line: #3D3C38;
--fg1: #F3EFE6;
--fg2: #BDBAB1;
--fg3: #9E9B92;
--accent: #8D0DE3;
--accent-text: #CBA5FD;
--accent-soft: #2F0454;
--danger: #F49E9E;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--fg1);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; }
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 600;
margin: 0;
letter-spacing: -0.02em;
}
.wrap {
max-width: var(--content-max);
margin: 0 auto;
padding-inline: var(--gutter);
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
background: var(--accent);
color: #fff;
padding: 10px 16px;
border-radius: var(--radius-input);
z-index: 100;
}
.skip-link:focus { left: 16px; top: 16px; }
.eyebrow {
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent-text);
margin: 0 0 12px;
}
/* ---------- buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: var(--font-body);
font-weight: 600;
text-decoration: none;
border-radius: var(--radius-pill);
border: 1px solid transparent;
cursor: pointer;
transition: transform var(--motion-fast) var(--ease), opacity var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease);
white-space: nowrap;
}
.btn:active { transform: scale(0.98); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { opacity: 0.92; }
.btn-outline { background: transparent; color: var(--fg1); border-color: var(--line); }
.btn-outline:hover { background: var(--surface2); }
.btn-lg { padding: 14px 26px; font-size: 16px; }
.btn-sm { padding: 9px 18px; font-size: 14px; }
/* ---------- header ---------- */
.site-header {
position: sticky;
top: 0;
z-index: 40;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.header-row {
display: flex;
align-items: center;
gap: 24px;
height: 72px;
}
.wordmark {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 700;
font-size: 20px;
text-decoration: none;
color: var(--fg1);
margin-right: auto;
}
.wordmark-sm { font-size: 17px; }
.wordmark-mark {
width: 26px;
height: 26px;
border-radius: 8px;
background: var(--accent);
position: relative;
flex: none;
}
.wordmark-mark::after {
content: "";
position: absolute;
inset: 7px;
border-radius: 4px;
background: #fff;
opacity: 0.92;
}
.site-nav {
display: flex;
gap: 28px;
}
.site-nav a {
font-size: 15px;
font-weight: 500;
text-decoration: none;
color: var(--fg2);
transition: color var(--motion-fast) var(--ease);
}
.site-nav a:hover { color: var(--fg1); }
.nav-toggle {
display: none;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 40px;
height: 40px;
border: none;
background: none;
cursor: pointer;
margin-left: -6px;
}
.nav-toggle span {
display: block;
height: 2px;
border-radius: 2px;
background: var(--fg1);
transition: transform var(--motion-base) var(--ease), opacity var(--motion-base) var(--ease);
}
.nav-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.nav-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
.mobile-nav {
display: none;
flex-direction: column;
gap: 4px;
padding: 8px var(--gutter) 20px;
border-top: 1px solid var(--line);
}
.mobile-nav a {
padding: 12px 4px;
text-decoration: none;
color: var(--fg1);
font-weight: 500;
border-bottom: 1px solid var(--line);
}
.mobile-nav a:last-child { border-bottom: none; margin-top: 8px; text-align: center; }
.mobile-nav.is-open { display: flex; }
@media (max-width: 760px) {
.site-nav, .header-row > .btn { display: none; }
.nav-toggle { display: flex; }
}
/* ---------- hero ---------- */
.hero { padding: clamp(48px, 8vw, 96px) 0 clamp(40px, 6vw, 72px); overflow: clip; }
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: clamp(32px, 6vw, 64px);
align-items: center;
}
.hero-copy h1 {
font-size: clamp(38px, 5.2vw, 60px);
line-height: 1.02;
margin-bottom: 20px;
}
.hero-copy h1 em {
font-style: normal;
color: var(--accent-text);
}
.hero-sub {
font-size: clamp(16px, 1.6vw, 18px);
color: var(--fg2);
max-width: 46ch;
margin: 0 0 28px;
}
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; }
.hero-proof {
list-style: none;
margin: 28px 0 0;
padding: 0;
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 13px;
color: var(--fg3);
}
.hero-proof li { display: flex; align-items: center; gap: 7px; }
.hero-proof li::before {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
}
/* ---------- hero exploded visual ---------- */
.hero-visual {
position: relative;
height: clamp(360px, 46vw, 520px);
display: flex;
align-items: center;
justify-content: center;
}
.glow {
position: absolute;
inset: -10%;
background: radial-gradient(closest-side, color-mix(in srgb, var(--accent) 16%, transparent), transparent 70%);
pointer-events: none;
}
.phone {
position: relative;
width: clamp(180px, 19vw, 220px);
aspect-ratio: 9 / 18.5;
background: var(--fg1);
border-radius: 34px;
padding: 10px;
will-change: transform;
}
.phone-notch {
position: absolute;
top: 10px; left: 50%;
transform: translateX(-50%);
width: 34%; height: 16px;
background: var(--fg1);
border-radius: 0 0 12px 12px;
z-index: 2;
}
.phone-screen {
position: relative;
height: 100%;
background: var(--bg);
border-radius: 24px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mini-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 14px 8px;
}
.mini-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; color: var(--fg1); }
.mini-dot { width: 16px; height: 16px; border-radius: 50%; background: var(--surface2); }
.mini-list { flex: 1; padding: 4px 10px; display: flex; flex-direction: column; gap: 6px; }
.mini-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border-radius: 10px;
padding: 7px 8px;
}
.mini-row-active { background: var(--accent-soft); }
.mini-ic { width: 16px; height: 16px; border-radius: 5px; flex: none; }
.ic-folder { background: var(--accent); opacity: 0.85; }
.ic-image { background: #2940A8; opacity: 0.8; }
.ic-file { background: var(--fg2); opacity: 0.6; }
.ic-share { background: #3E7A23; opacity: 0.85; }
.ic-lock { background: var(--fg1); opacity: 0.9; }
.mini-lines { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.mini-lines b, .mini-lines i {
display: block;
height: 5px;
border-radius: 3px;
background: var(--fg2);
opacity: 0.35;
font-style: normal;
}
.mini-lines b { width: 70%; opacity: 0.55; }
.mini-lines i { width: 40%; }
.mini-bar {
display: flex;
align-items: center;
justify-content: space-around;
padding: 10px 8px;
border-top: 1px solid var(--line);
position: relative;
}
.mini-bar-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg3); opacity: 0.4; }
.mini-bar-pill {
position: absolute;
left: 10px;
width: 20px; height: 10px;
border-radius: var(--radius-pill);
background: var(--accent-soft);
}
.chip {
position: absolute;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-card);
will-change: transform;
}
.chip-photo {
top: 6%; right: 2%;
width: 92px; height: 92px;
padding: 8px;
transform: rotate(-6deg);
}
.chip-photo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; height: 100%; }
.chip-photo-grid span:nth-child(1) { background: #8DCDE2; border-radius: 8px; }
.chip-photo-grid span:nth-child(2) { background: #E5D8BD; border-radius: 8px; }
.chip-photo-grid span:nth-child(3) { background: #DA9AC7; border-radius: 8px; }
.chip-photo-grid span:nth-child(4) { background: #A7D296; border-radius: 8px; }
.chip-row {
bottom: 20%; right: -2%;
display: flex; align-items: center; gap: 10px;
padding: 12px 16px;
transform: rotate(4deg);
width: 160px;
}
.chip-swatches {
bottom: 6%; left: 4%;
display: flex; gap: 8px;
padding: 12px;
transform: rotate(-4deg);
}
.chip-swatches .swatch { width: 18px; height: 18px; border-radius: 50%; background: var(--c); }
.chip-toggle {
top: 16%; left: -4%;
display: flex; align-items: center; gap: 14px;
padding: 12px 14px;
width: 150px;
transform: rotate(3deg);
}
.mini-lines-toggle { flex: 1; }
.mini-toggle { width: 30px; height: 18px; border-radius: var(--radius-pill); background: var(--surface3); position: relative; flex: none; }
.mini-toggle i { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--fg1); opacity: 0.3; }
.mini-toggle-on { background: var(--accent); }
.mini-toggle-on i { left: 14px; background: #fff; opacity: 1; }
.chip-badge {
bottom: -2%; left: 30%;
display: flex; align-items: center; gap: 8px;
padding: 9px 14px;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-pill);
transform: rotate(-2deg);
}
.chip-badge .mini-ic { width: 14px; height: 14px; }
@media (max-width: 980px) {
.hero-grid { grid-template-columns: 1fr; text-align: center; }
.hero-copy { order: 2; }
.hero-copy .hero-sub { margin-inline: auto; }
.hero-actions, .hero-proof { justify-content: center; }
.hero-visual { order: 1; }
}
/* ---------- sections ---------- */
.section { padding: clamp(56px, 8vw, 104px) 0; }
.section-alt { background: var(--surface); }
.section h2 {
font-size: clamp(28px, 3.4vw, 42px);
line-height: 1.08;
max-width: 20ch;
}
.section-sub {
font-size: clamp(15px, 1.4vw, 17px);
color: var(--fg2);
max-width: 56ch;
margin: 16px 0 0;
}
.grid { display: grid; gap: 20px; margin-top: 44px; }
.grid-features { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.card {
background: var(--surface);
border-radius: var(--radius-card);
padding: 28px;
}
.section-alt .card { background: var(--bg); }
.card h3 {
font-size: 19px;
margin: 16px 0 8px;
}
.card p { margin: 0; color: var(--fg2); font-size: 15px; }
.card-ic, .pt-ic {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px; height: 44px;
border-radius: var(--radius-input);
background: var(--accent-soft);
position: relative;
}
.card-ic::after, .pt-ic::after {
content: "";
width: 20px; height: 20px;
background: var(--accent-text);
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
}
/* Minimal inline icon set (stroke-style, Lucide-ish) via CSS mask so the
whole page stays dependency-free - no icon font, no SVG sprite fetch. */
.ic-folder-lg::after { -webkit-mask-image: var(--i-folder); mask-image: var(--i-folder); }
.ic-image-lg::after { -webkit-mask-image: var(--i-image); mask-image: var(--i-image); }
.ic-download-lg::after { -webkit-mask-image: var(--i-download); mask-image: var(--i-download); }
.ic-share-lg::after { -webkit-mask-image: var(--i-share); mask-image: var(--i-share); }
.ic-clock-lg::after { -webkit-mask-image: var(--i-clock); mask-image: var(--i-clock); }
.ic-users-lg::after { -webkit-mask-image: var(--i-users); mask-image: var(--i-users); }
.pt-ic.ic-lock::after { -webkit-mask-image: var(--i-lock); mask-image: var(--i-lock); }
.pt-ic.ic-server::after { -webkit-mask-image: var(--i-server); mask-image: var(--i-server); }
.pt-ic.ic-code::after { -webkit-mask-image: var(--i-code); mask-image: var(--i-code); }
.pt-ic.ic-off::after { -webkit-mask-image: var(--i-off); mask-image: var(--i-off); }
:root {
--i-folder: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z'/%3E%3C/svg%3E");
--i-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2'/%3E%3Ccircle cx='9' cy='9' r='2'/%3E%3Cpath d='m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21'/%3E%3C/svg%3E");
--i-download: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' y1='15' x2='12' y2='3'/%3E%3C/svg%3E");
--i-share: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cline x1='8.6' y1='10.5' x2='15.4' y2='6.5'/%3E%3Cline x1='8.6' y1='13.5' x2='15.4' y2='17.5'/%3E%3C/svg%3E");
--i-clock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpolyline points='12 7 12 12 15.5 14.5'/%3E%3C/svg%3E");
--i-users: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='9' cy='7' r='4'/%3E%3Cpath d='M22 21v-2a4 4 0 0 0-3-3.87'/%3E%3Cpath d='M16 3.13a4 4 0 0 1 0 7.75'/%3E%3C/svg%3E");
--i-lock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='11' width='18' height='11' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
--i-server: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='3' width='20' height='7' rx='1.5'/%3E%3Crect x='2' y='14' width='20' height='7' rx='1.5'/%3E%3Cline x1='6' y1='6.5' x2='6.01' y2='6.5'/%3E%3Cline x1='6' y1='17.5' x2='6.01' y2='17.5'/%3E%3C/svg%3E");
--i-code: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='16 18 22 12 16 6'/%3E%3Cpolyline points='8 6 2 12 8 18'/%3E%3C/svg%3E");
--i-off: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='2' y1='2' x2='22' y2='22'/%3E%3Cpath d='M16.9 16.9A9 9 0 0 1 4 12a9 9 0 0 1 2.6-6.3'/%3E%3Cpath d='M9.9 4.24A9 9 0 0 1 20 12a9 9 0 0 1-.8 3.7'/%3E%3C/svg%3E");
}
@media (max-width: 900px) { .grid-features { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .grid-features { grid-template-columns: 1fr; } }
/* ---------- customize ---------- */
.customize-grid {
margin-top: 44px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.customize-demo {
background: var(--bg);
border-radius: var(--radius-card);
padding: 24px;
display: flex;
flex-direction: column;
gap: 22px;
}
.customize-demo h3 { font-size: 18px; margin: 0 0 8px; }
.customize-demo p { margin: 0; color: var(--fg2); font-size: 14px; }
.demo-preview {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-input);
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.demo-topbar { display: flex; align-items: center; gap: 10px; }
.demo-dot { width: 12px; height: 12px; border-radius: 50%; transition: background var(--motion-base) var(--ease); }
.demo-line { display: block; height: 8px; border-radius: 4px; background: var(--surface3); }
.demo-pill {
align-self: flex-start;
padding: 6px 14px;
border-radius: var(--radius-pill);
font-size: 13px;
font-weight: 600;
transition: background var(--motion-base) var(--ease), color var(--motion-base) var(--ease);
}
.demo-row { padding: 6px 0; }
.swatch-row { display: flex; gap: 10px; }
.swatch-btn {
width: 32px; height: 32px;
border-radius: 50%;
border: 2px solid transparent;
background: var(--c);
cursor: pointer;
padding: 0;
transition: transform var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease);
}
.swatch-btn:last-child {
background: linear-gradient(135deg, #8DCDE2, #DA9AC7 50%, #E5D8BD);
}
.swatch-btn.is-active { border-color: var(--fg1); transform: scale(1.08); }
.swatch-btn:hover { transform: scale(1.08); }
.demo-preview-bar { position: relative; height: 128px; padding: 0; overflow: hidden; }
.demo-screen-fill {
position: absolute; inset: 0;
background: repeating-linear-gradient(180deg, var(--surface2) 0 34px, var(--surface) 34px 68px);
opacity: 0.6;
}
.demo-bar {
position: absolute;
left: 12px; right: 12px; bottom: 12px;
height: 44px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-pill);
display: flex;
align-items: center;
justify-content: space-around;
transition: left var(--motion-base) var(--ease), right var(--motion-base) var(--ease), bottom var(--motion-base) var(--ease), border-radius var(--motion-base) var(--ease);
}
.demo-bar.is-attached { left: 0; right: 0; bottom: 0; border-radius: 0; border-width: 1px 0 0; }
.demo-bar-item { width: 18px; height: 18px; border-radius: 6px; background: var(--surface3); transition: background var(--motion-fast) var(--ease); }
.demo-bar-item.is-on { background: var(--accent); }
.segmented {
display: inline-flex;
padding: 3px;
background: var(--surface);
border-radius: var(--radius-pill);
gap: 2px;
align-self: flex-start;
}
.segmented-btn {
border: none;
background: transparent;
padding: 8px 16px;
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
color: var(--fg2);
cursor: pointer;
transition: background var(--motion-fast) var(--ease), color var(--motion-fast) var(--ease);
}
.segmented-btn.is-active { background: var(--accent-soft); color: var(--accent-text); }
.customize-list {
grid-column: 1 / -1;
background: var(--bg);
border-radius: var(--radius-card);
padding: 28px 32px;
}
.customize-list h3 { font-size: 18px; margin: 0 0 16px; }
.feature-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 32px;
color: var(--fg2);
font-size: 15px;
}
.feature-list li { display: flex; align-items: center; gap: 10px; }
.feature-list li::before {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
@media (max-width: 760px) {
.customize-grid { grid-template-columns: 1fr; }
.feature-list { grid-template-columns: 1fr; }
}
/* ---------- privacy ---------- */
.privacy-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: clamp(32px, 6vw, 64px);
align-items: center;
}
.privacy-points {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 14px;
}
.privacy-points li {
display: flex;
align-items: center;
gap: 14px;
background: var(--surface);
border-radius: var(--radius-input);
padding: 16px 18px;
font-weight: 500;
}
@media (max-width: 820px) {
.privacy-grid { grid-template-columns: 1fr; }
}
/* ---------- cta ---------- */
.section-cta { text-align: center; background: var(--surface); }
.cta-inner { display: flex; flex-direction: column; align-items: center; }
.cta-inner .section-sub { margin-bottom: 32px; }
.cta-actions { justify-content: center; }
.cta-fine { margin-top: 20px; font-size: 14px; color: var(--fg3); }
.cta-fine a { color: var(--accent-text); font-weight: 600; text-decoration: none; }
/* ---------- footer ---------- */
.site-footer { border-top: 1px solid var(--line); padding: 32px 0; }
.footer-row {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.footer-note { color: var(--fg3); font-size: 13px; margin: 0; flex: 1; min-width: 200px; }
.footer-links { display: flex; gap: 20px; }
.footer-links a { font-size: 14px; text-decoration: none; color: var(--fg2); }
.footer-links a:hover { color: var(--fg1); }
/* ---------- motion ---------- */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.phone, .chip, .btn { transition: none !important; }
}
.reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 600ms var(--ease), transform 600ms var(--ease);
}
.reveal.is-visible { opacity: 1; transform: none; }