Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed747a0407 | ||
|
|
9c9e57ec22 | ||
|
|
8aa7015c3f | ||
|
|
88abca647d | ||
|
|
65b772d6e7 | ||
|
|
8179bf9683 | ||
|
|
af69e5e751 | ||
|
|
1a232fc71a | ||
|
|
16aadda7b8 |
@@ -801,3 +801,5 @@ Flutter's own default to already be high enough.
|
||||
`isRestoringSession` still gates the splash screen until the above resolves
|
||||
— see `standards.md` for why widget tests must mock both storage channels
|
||||
rather than relying on this async path throwing naturally.
|
||||
|
||||
- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`).
|
||||
|
||||
@@ -90,32 +90,6 @@ class/method already makes obvious.
|
||||
`Align`).
|
||||
- Run with `flutter test`.
|
||||
|
||||
## Play Store screenshots
|
||||
|
||||
`bash tool/screenshots.sh` (Git Bash is fine on Windows) regenerates the store
|
||||
listing screenshots **without any real data**: it runs the real, unmodified app
|
||||
against a throwaway Docker Nextcloud (`tool/demo_server/`) seeded with invented
|
||||
content, on a wiped 1080x1920 emulator with a demo-mode status bar, and drives
|
||||
it with `integration_test/store_screenshots_test.dart` (host-side driver:
|
||||
`test_driver/integration_test.dart`, which saves `adb screencap` images).
|
||||
Output lands in `store_listing/screenshots/` (gitignored) after
|
||||
`tool/finalize_screenshots.py` flattens alpha and checks Play's size/aspect
|
||||
limits (each side 320-3840 px, long side at most 2x the short side - a stock
|
||||
1080x2400 phone screen is 2.22:1 and would be rejected).
|
||||
|
||||
- The test logs in by writing the demo user's account/app password into
|
||||
storage via `AccountStore` before calling `main()`, so it skips the browser
|
||||
login flow; it also fixes the theme (light, non-dynamic colour), the visible
|
||||
tabs and the first-run notification prompt so shots are reproducible.
|
||||
- It finds things by tab icon (`AppTab.icon` inside `FloatingBottomNavBar`),
|
||||
tooltips, the `ValueKey('files')`/`ValueKey('offline')` on the two
|
||||
`FilesView`s, and the fake content's names (defined in
|
||||
`tool/demo_server/seed.py`) - keep those in sync if you rename either side.
|
||||
- `flutter test` only runs `test/`, so this never runs as part of the normal
|
||||
suite; `integration_test` is a dev-only dependency.
|
||||
- Always review the images before uploading - see
|
||||
`tool/demo_server/README.md` for the known places real data could appear.
|
||||
|
||||
## Local install/deploy
|
||||
|
||||
Never use `flutter install` to push a build to a test device — it always
|
||||
@@ -157,3 +131,16 @@ Networking is deliberately split: `package:http` for simple JSON/XML
|
||||
request-response calls, `package:dio` only where streaming/progress is
|
||||
needed (downloads). Don't introduce a third HTTP client — extend the
|
||||
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.
|
||||
|
||||
@@ -30,10 +30,12 @@ widgets. Key points:
|
||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||
- **Font**: Inter via `google_fonts`, applied through
|
||||
`GoogleFonts.interTextTheme(...)`. In tests, set
|
||||
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without
|
||||
it, the font-fetch call to Google's CDN can stall `pumpAndSettle`
|
||||
indefinitely (see `standards.md`).
|
||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||
`main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
|
||||
app never contacts Google (the privacy policy on the website relies on
|
||||
this — keep it true; add new weights as bundled files). Tests set the
|
||||
same flag in `setUpAll` (see `standards.md`).
|
||||
- **Cards**: flat (`elevation: 0`), 20px rounded corners,
|
||||
`surfaceContainerLow`.
|
||||
- **App bars**: flat, not centered, `surface` background.
|
||||
@@ -324,3 +326,13 @@ blocks are noted where they matter:
|
||||
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
|
||||
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
|
||||
layer), so it renders at a sensible size at 72-80px.
|
||||
|
||||
## Text/markdown viewer
|
||||
|
||||
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
|
||||
|
||||
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
|
||||
|
||||
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
|
||||
|
||||
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
|
||||
|
||||
@@ -39,6 +39,16 @@ jobs:
|
||||
# platform/build-tools versions it actually needs on its own.
|
||||
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
|
||||
run: flutter pub get
|
||||
|
||||
@@ -67,6 +77,7 @@ jobs:
|
||||
mkdir -p release
|
||||
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
|
||||
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Publish Gitea release with App Bundle attached
|
||||
# Gitea-native release action (not GitHub's) - authenticates and
|
||||
@@ -78,26 +89,17 @@ jobs:
|
||||
with:
|
||||
files: ${{ env.AAB_PATH }}
|
||||
|
||||
- name: Decode Play Console service account key
|
||||
# Skipped (rather than failing the whole run) until the secret is
|
||||
# set up - see tool/upload_play_bundle.py's doc comment for what
|
||||
# that setup involves and its one hard prerequisite: Google won't
|
||||
# accept API calls for an app that's never had a single manual
|
||||
# upload through the Play Console web UI.
|
||||
if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }}
|
||||
run: echo "$SERVICE_ACCOUNT_JSON_BASE64" | base64 -d > "${{ runner.temp }}/play-service-account.json"
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
||||
|
||||
- name: Upload App Bundle to Play Console library
|
||||
# Deliberately doesn't touch any release track - see
|
||||
# tool/upload_play_bundle.py. Uploaded bundles show up under
|
||||
# Release > App bundle explorer in Play Console, ready to promote
|
||||
# to a track by hand whenever it's actually time to ship.
|
||||
if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }}
|
||||
run: |
|
||||
pip install --quiet google-api-python-client google-auth
|
||||
python3 tool/upload_play_bundle.py "${{ env.AAB_PATH }}"
|
||||
env:
|
||||
GOOGLE_PLAY_PACKAGE_NAME: dev.ayushya.noo
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH: ${{ runner.temp }}/play-service-account.json
|
||||
- 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
|
||||
|
||||
@@ -45,6 +45,3 @@ app.*.map.json
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Play Store screenshot tooling (tool/screenshots.sh)
|
||||
/store_listing/screenshots/
|
||||
/tool/demo_server/.venv/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
Redesigned Noo interface, multi-account support, app locks, offline sync, appearance customization, and a markdown viewer and editor.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -44,6 +45,8 @@ import 'widgets/shell/shell_common.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// Fonts ship in assets/google_fonts; never fetch them from Google at runtime.
|
||||
GoogleFonts.config.allowRuntimeFetching = false;
|
||||
runApp(
|
||||
MultiProvider(
|
||||
// Split out of the former single `ServerProvider` god object - see
|
||||
|
||||
@@ -147,6 +147,19 @@ class NextcloudService {
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
/// Overwrites the file at [itemPath] with [bytes] (WebDAV PUT) - used by
|
||||
/// the in-app text editor. Returns whether the server accepted it.
|
||||
Future<bool> putBytes(String itemPath, List<int> bytes) async {
|
||||
final response = await http.put(
|
||||
Uri.parse(fileUrl(itemPath)),
|
||||
headers: _headers,
|
||||
body: bytes,
|
||||
);
|
||||
return response.statusCode == 200 ||
|
||||
response.statusCode == 201 ||
|
||||
response.statusCode == 204;
|
||||
}
|
||||
|
||||
Future<bool> testConnection() async {
|
||||
try {
|
||||
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
|
||||
|
||||
@@ -109,7 +109,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
final Widget body = _ios
|
||||
? _buildIos(colors, lead)
|
||||
: _buildAndroid(colors, lead);
|
||||
: _buildAndroid(context, colors, lead);
|
||||
|
||||
return _NooTopBarScope(
|
||||
style: style,
|
||||
@@ -177,7 +177,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAndroid(NooColors colors, Widget? lead) {
|
||||
Widget _buildAndroid(BuildContext context, NooColors colors, Widget? lead) {
|
||||
return SizedBox(
|
||||
height: _androidRow,
|
||||
child: Padding(
|
||||
@@ -189,14 +189,16 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
spacing: 4,
|
||||
children: [
|
||||
?lead,
|
||||
// [androidTitleTrailing]'s own doc comment explains why this
|
||||
// isn't a straight replacement: the title keeps its natural
|
||||
// width (`Flexible`, not `Expanded` - it still shrinks/
|
||||
// ellipsizes if there's truly no room, but doesn't force the
|
||||
// row that wide otherwise) and sits to the trailing content's
|
||||
// left rather than being displaced by it.
|
||||
// The title is a non-flex child capped at 40% of the screen
|
||||
// width (so it keeps its natural width but still ellipsizes),
|
||||
// leaving the `Expanded` trailing widget all the remaining
|
||||
// space. A `Flexible` title would instead split the free space
|
||||
// 1:1 with it, since both default to flex 1.
|
||||
if (androidTitleTrailing != null) ...[
|
||||
Flexible(
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.4,
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
@@ -204,6 +206,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
style: NooText.title.copyWith(height: 1, color: colors.fg1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: NooSpace.md),
|
||||
Expanded(child: androidTitleTrailing!),
|
||||
] else
|
||||
Expanded(
|
||||
|
||||
@@ -64,6 +64,8 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
final String permission;
|
||||
final VoidCallback? onPermissionTap;
|
||||
final String ownerLabel;
|
||||
/// Replaces the owner label / permission pill (e.g. a plain search result).
|
||||
final Widget? trailing;
|
||||
|
||||
const NooPersonAccessRow({
|
||||
super.key,
|
||||
@@ -74,6 +76,7 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
this.permission = 'Can edit',
|
||||
this.onPermissionTap,
|
||||
this.ownerLabel = 'Owner',
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -113,7 +116,9 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
if (owner)
|
||||
if (trailing != null)
|
||||
trailing!
|
||||
else if (owner)
|
||||
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
|
||||
else
|
||||
NooPermissionPill(label: permission, onTap: onPermissionTap),
|
||||
|
||||
@@ -106,13 +106,23 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
// A modal bottom sheet doesn't avoid the keyboard itself, so a focused
|
||||
// field would be covered. Inset by the keyboard and, while it's up, let
|
||||
// the content use all the space above it.
|
||||
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final keyboardUp = keyboard > 0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: keyboard),
|
||||
child: SafeArea(
|
||||
bottom: !keyboardUp,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (!_needsPeek) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction,
|
||||
maxHeight: keyboardUp
|
||||
? constraints.maxHeight
|
||||
: constraints.maxHeight * _kNooSheetPeekFraction,
|
||||
),
|
||||
child: _buildScrollable(null),
|
||||
);
|
||||
@@ -141,6 +151,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -71,11 +72,13 @@ class ShareSheet extends StatefulWidget {
|
||||
return showNooSheet(
|
||||
context,
|
||||
children: [
|
||||
NooOverlayHeader(
|
||||
Builder(
|
||||
builder: (sheetContext) => NooOverlayHeader(
|
||||
leading: detailsFileTile(item),
|
||||
title: item.name,
|
||||
subtitle: detailsMetaLine(item),
|
||||
onClose: () => Navigator.pop(context),
|
||||
onClose: () => Navigator.of(sheetContext).pop(),
|
||||
),
|
||||
),
|
||||
ShareSheet(item: item),
|
||||
],
|
||||
@@ -99,9 +102,36 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
bool _showInherited = false;
|
||||
final _peopleController = TextEditingController();
|
||||
final _linkController = TextEditingController();
|
||||
final _peopleFocus = FocusNode();
|
||||
final _peopleKey = GlobalKey();
|
||||
|
||||
/// Once the user types, scrolls the sheet so the people field and its
|
||||
/// results (under their title) sit near the top, leaving a little room under the sheet's
|
||||
/// rounded top edge.
|
||||
void _revealPeopleSection() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final ctx = _peopleKey.currentContext;
|
||||
final box = ctx?.findRenderObject();
|
||||
if (ctx == null || box is! RenderBox) return;
|
||||
final position = Scrollable.maybeOf(ctx)?.position;
|
||||
final viewport = RenderAbstractViewport.maybeOf(box);
|
||||
if (position == null || viewport == null) return;
|
||||
final target = (viewport.getOffsetToReveal(box, 0).offset - 8).clamp(
|
||||
position.minScrollExtent,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
position.animateTo(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_peopleFocus.dispose();
|
||||
_peopleController.dispose();
|
||||
_linkController.dispose();
|
||||
super.dispose();
|
||||
@@ -128,6 +158,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
return;
|
||||
}
|
||||
setState(() => _isSearching = true);
|
||||
_revealPeopleSection();
|
||||
final ops = context.read<ItemOperations>();
|
||||
final results = await ops.searchSharees(trimmed);
|
||||
if (!mounted) return;
|
||||
@@ -135,6 +166,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
_searchResults = results;
|
||||
_isSearching = false;
|
||||
});
|
||||
_revealPeopleSection();
|
||||
}
|
||||
|
||||
/// The single "Name, email or group" field: reuses whichever match is
|
||||
@@ -364,6 +396,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
absorbing: _isAddingPerson,
|
||||
child: NooTextField(
|
||||
controller: _peopleController,
|
||||
focusNode: _peopleFocus,
|
||||
leadingIcon: LucideIcons.search,
|
||||
placeholder: 'Name, email or group',
|
||||
onChanged: _search,
|
||||
@@ -381,17 +414,34 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
),
|
||||
),
|
||||
for (final sharee in _searchResults)
|
||||
NooSettingsRow(
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
onTap: () => _addSharee(sharee),
|
||||
child: NooPersonAccessRow(
|
||||
avatar: NooAvatar(
|
||||
initials: _initial(sharee.label),
|
||||
color: NooPhotoTile.paletteColor(sharee.label.hashCode),
|
||||
icon: sharee.type == ShareeType.group
|
||||
? LucideIcons.users
|
||||
: LucideIcons.user,
|
||||
label: Text(sharee.label),
|
||||
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
|
||||
onTap: () => _addSharee(sharee),
|
||||
: null,
|
||||
),
|
||||
name: sharee.label,
|
||||
subtitle: sharee.subtitle,
|
||||
trailing: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (_searchResults.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: NooSpace.xs),
|
||||
child: Divider(height: 1, thickness: 1, color: colors.line),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: NooSpace.xs),
|
||||
NooPersonAccessRow(
|
||||
avatar: NooAvatar(initials: _initial(session.username), current: true),
|
||||
avatar: NooAvatar(
|
||||
initials: _initial(session.username),
|
||||
current: true,
|
||||
),
|
||||
name: session.username,
|
||||
owner: true,
|
||||
),
|
||||
@@ -402,12 +452,16 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
color: NooPhotoTile.paletteColor(
|
||||
(share.sharedWithDisplayName ?? share.id).hashCode,
|
||||
),
|
||||
icon: share.shareType == ShareType.group ? LucideIcons.users : null,
|
||||
icon: share.shareType == ShareType.group
|
||||
? LucideIcons.users
|
||||
: null,
|
||||
),
|
||||
name: share.sharedWithDisplayName ?? 'Shared',
|
||||
subtitle: share.shareType == ShareType.group
|
||||
? 'Group'
|
||||
: (share.shareType == ShareType.email ? 'Invited by email' : null),
|
||||
: (share.shareType == ShareType.email
|
||||
? 'Invited by email'
|
||||
: null),
|
||||
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
|
||||
onPermissionTap: () => _openPersonMenu(share),
|
||||
),
|
||||
@@ -429,7 +483,9 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||
_showInherited
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 16,
|
||||
color: colors.fg3,
|
||||
),
|
||||
@@ -464,7 +520,10 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||
Text(
|
||||
'Internal link',
|
||||
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||
),
|
||||
Text(
|
||||
'For people who already have access',
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
@@ -559,6 +618,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
NooShareSection(
|
||||
key: _peopleKey,
|
||||
title: 'Share with people',
|
||||
child: _peopleSection(colors, session, peopleShares, internalLink),
|
||||
),
|
||||
@@ -572,13 +632,16 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
caption: hasLink
|
||||
? null
|
||||
: 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.',
|
||||
child: hasLink ? _linkSection(publicLinkShares.first) : const SizedBox.shrink(),
|
||||
child: hasLink
|
||||
? _linkSection(publicLinkShares.first)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
if (!widget.item.isFolder) ...[
|
||||
const SizedBox(height: 22),
|
||||
NooShareSection(
|
||||
title: 'Send file directly',
|
||||
caption: "Link settings above don't apply - this sends the file's bytes directly.",
|
||||
caption:
|
||||
"Link settings above don't apply - this sends the file's bytes directly.",
|
||||
child: NooButton(
|
||||
variant: NooButtonVariant.outline,
|
||||
size: NooButtonSize.card,
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_button.dart';
|
||||
|
||||
/// Plain monospace text-file preview for [FileViewerScreen]'s static (non-
|
||||
/// swipeable) preview path (`_textPreviewExtensions`).
|
||||
/// Space the viewer's floating top bar and bottom action bar cover, so the
|
||||
/// text sits clear of them (and the status bar) instead of scrolling
|
||||
/// underneath.
|
||||
const double _kTopBarClearance = 72;
|
||||
const double _kActionBarClearance = 112;
|
||||
|
||||
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
|
||||
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
|
||||
/// editable when online; a Save button appears once it has changed. Markdown
|
||||
/// files open rendered, with an Edit/Preview toggle. Opened
|
||||
/// from the Offline tab ([localPathResolver] set) it is read-only, since the
|
||||
/// local copy has no server to write back to.
|
||||
class MediaTextPreview extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
final SessionController session;
|
||||
@@ -28,15 +41,35 @@ class MediaTextPreview extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
String? _content;
|
||||
final _controller = TextEditingController();
|
||||
String? _saved;
|
||||
String? _error;
|
||||
bool _isSaving = false;
|
||||
bool _editing = false;
|
||||
|
||||
bool get _isMarkdown {
|
||||
final name = widget.item.name.toLowerCase();
|
||||
return name.endsWith('.md') || name.endsWith('.markdown');
|
||||
}
|
||||
|
||||
bool get _showRendered => _isMarkdown && !_editing;
|
||||
|
||||
bool get _readOnly => widget.localPathResolver != null;
|
||||
bool get _dirty => _saved != null && _controller.text != _saved;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(() => setState(() {}));
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final List<int> bytes;
|
||||
@@ -53,13 +86,39 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
bytes = await widget.session.service!.fetchBytes(widget.item.path);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _content = utf8.decode(bytes, allowMalformed: true));
|
||||
final text = utf8.decode(bytes, allowMalformed: true);
|
||||
_controller.text = text;
|
||||
setState(() => _saved = text);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final text = _controller.text;
|
||||
setState(() => _isSaving = true);
|
||||
var ok = false;
|
||||
try {
|
||||
ok = await widget.session.service!.putBytes(
|
||||
widget.item.path,
|
||||
utf8.encode(text),
|
||||
);
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
if (ok) _saved = text;
|
||||
});
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
@@ -71,18 +130,103 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_content == null) {
|
||||
if (_saved == null) {
|
||||
return Center(child: CircularProgressIndicator(color: colors.accent));
|
||||
}
|
||||
final safe = MediaQuery.paddingOf(context);
|
||||
final style = NooText.mono.copyWith(color: colors.fg1, height: 1.5);
|
||||
final padding = EdgeInsets.fromLTRB(
|
||||
20,
|
||||
safe.top + _kTopBarClearance,
|
||||
20,
|
||||
safe.bottom + _kActionBarClearance,
|
||||
);
|
||||
return Container(
|
||||
color: colors.surface,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
_content!,
|
||||
style: NooText.mono.copyWith(color: colors.fg1, height: 1.5),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _showRendered
|
||||
? Markdown(
|
||||
data: _controller.text,
|
||||
padding: padding,
|
||||
selectable: true,
|
||||
styleSheet: _markdownStyle(context, colors),
|
||||
onTapLink: (_, href, _) {},
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: padding,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
readOnly: _readOnly,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: style,
|
||||
cursorColor: colors.accentText,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isMarkdown && !_readOnly)
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: safe.top + _kTopBarClearance - 8,
|
||||
child: NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.compact,
|
||||
icon: _editing ? LucideIcons.eye : LucideIcons.pencil,
|
||||
onTap: () => setState(() => _editing = !_editing),
|
||||
child: Text(_editing ? 'Preview' : 'Edit'),
|
||||
),
|
||||
),
|
||||
if (_dirty)
|
||||
Positioned(
|
||||
right: 20,
|
||||
bottom: safe.bottom + _kActionBarClearance,
|
||||
child: NooButton(
|
||||
size: NooButtonSize.card,
|
||||
icon: LucideIcons.save,
|
||||
disabled: _isSaving,
|
||||
onTap: _isSaving ? null : _save,
|
||||
child: Text(_isSaving ? 'Saving…' : 'Save'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MarkdownStyleSheet _markdownStyle(BuildContext context, NooColors colors) {
|
||||
final body = NooText.body.copyWith(color: colors.fg1, height: 1.55);
|
||||
TextStyle heading(double size) =>
|
||||
NooText.title.copyWith(color: colors.fg1, fontSize: size, height: 1.25);
|
||||
return MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
||||
p: body,
|
||||
h1: heading(26),
|
||||
h2: heading(22),
|
||||
h3: heading(19),
|
||||
h4: heading(17),
|
||||
listBullet: body,
|
||||
a: body.copyWith(color: colors.accentText),
|
||||
blockquote: body.copyWith(color: colors.fg2),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
border: Border(left: BorderSide(color: colors.line, width: 3)),
|
||||
),
|
||||
code: NooText.mono.copyWith(
|
||||
color: colors.fg1,
|
||||
backgroundColor: colors.surface2,
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: colors.surface2,
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
),
|
||||
horizontalRuleDecoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: colors.line)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+16
-39
@@ -254,11 +254,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -267,6 +262,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown_plus
|
||||
sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.12"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -333,11 +336,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -378,11 +376,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -503,6 +496,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.20"
|
||||
markdown:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: markdown
|
||||
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.1"
|
||||
marquee:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -791,14 +792,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.6"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -932,14 +925,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1084,14 +1069,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+3
-6
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
version: 1.0.0+3
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@@ -58,16 +58,12 @@ dependencies:
|
||||
webview_flutter: ^4.14.1
|
||||
permission_handler: ^11.3.1
|
||||
connectivity_plus: ^6.1.5
|
||||
flutter_markdown_plus: ^1.0.12
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# Drives tool/screenshots.sh (Play Store screenshots against a fake-data
|
||||
# demo server) - dev only, never part of the release build.
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
@@ -89,6 +85,7 @@ flutter:
|
||||
assets:
|
||||
- assets/icon/app_icon.png
|
||||
- assets/icon/app_icon_monochrome.png
|
||||
- assets/google_fonts/
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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}"
|
||||
@@ -1,2 +0,0 @@
|
||||
requests>=2.31
|
||||
Pillow>=10.0
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Uploads an .aab to the Play Console's app bundle library, without
|
||||
touching any release track.
|
||||
|
||||
Play Console itself doesn't expose "just upload, don't release" as an
|
||||
action - every path through the UI attaches a bundle to a track. The Play
|
||||
Developer API does support it though: open an edit, upload the bundle,
|
||||
commit the edit, and stop there. The result shows up under Release > App
|
||||
bundle explorer and nowhere else - nothing goes out to testers or
|
||||
production until it's promoted to a track by hand, whenever that's wanted.
|
||||
|
||||
Needs:
|
||||
GOOGLE_PLAY_PACKAGE_NAME e.g. dev.ayushya.noo
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH path to a service account key file
|
||||
|
||||
The Play Developer API can't create an app's first release - Google
|
||||
requires at least one manual upload through the Play Console web UI (to
|
||||
any track) before it'll accept API calls for that app at all.
|
||||
|
||||
python3 tool/upload_play_bundle.py path/to/app-release.aab
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
SCOPES = ["https://www.googleapis.com/auth/androidpublisher"]
|
||||
|
||||
|
||||
def main(bundle_path):
|
||||
package_name = os.environ["GOOGLE_PLAY_PACKAGE_NAME"]
|
||||
key_path = os.environ["GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH"]
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
key_path, scopes=SCOPES
|
||||
)
|
||||
service = build("androidpublisher", "v3", credentials=creds)
|
||||
|
||||
edit_id = service.edits().insert(body={}, packageName=package_name).execute()["id"]
|
||||
|
||||
bundle = (
|
||||
service.edits()
|
||||
.bundles()
|
||||
.upload(editId=edit_id, packageName=package_name, media_body=bundle_path)
|
||||
.execute()
|
||||
)
|
||||
print("Uploaded versionCode %s" % bundle["versionCode"])
|
||||
|
||||
service.edits().commit(editId=edit_id, packageName=package_name).execute()
|
||||
print(
|
||||
"Committed - visible under Release > App bundle explorer in Play "
|
||||
"Console. Not attached to any track, so nothing goes out to "
|
||||
"testers or production until it's promoted by hand."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1])
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 & 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>
|
||||
@@ -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"));
|
||||
}
|
||||
})();
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user