Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## Specs

The specs in `specs/` are the source of truth. Start with `specs/README.md` (reading order, conventions), then `specs/01-architecture.md` (layout, package rules, how the apps are served). App specs: `specs/wonky_write/`, `specs/wonky_sheets/`, `specs/wonky_drive/` and `specs/wonky_slides/` (v2 decisions: `specs/v2-review.md`). Cross-app features: `specs/16-bugs-and-improvements.md` (Bugs & improvements board, M12); the RevLog sync of Sheets and Slides: `specs/17-revlog-sync.md`. Blog submissions from Wonky Write (M14, built): `specs/18-publications-and-submissions.md`; the guide for blog owners is `docs/blog-integration.md`. When implementation reveals a better approach, update the spec in the same change.
The specs in `specs/` are the source of truth. Start with `specs/README.md` (reading order, conventions), then `specs/01-architecture.md` (layout, package rules, how the apps are served). App specs: `specs/wonky_write/`, `specs/wonky_sheets/`, `specs/wonky_drive/` and `specs/wonky_slides/` (v2 decisions: `specs/v2-review.md`). Cross-app features: `specs/16-bugs-and-improvements.md` (Bugs & improvements board, M12); the RevLog sync of Sheets and Slides: `specs/17-revlog-sync.md`. Blog submissions from Wonky Write (M14, built): `specs/18-publications-and-submissions.md`; the guide for blog owners is `docs/blog-integration.md`. First-party usage analytics for admins in Wonky Drive (`/drive/analytics`, built): `specs/19-usage-analytics.md`. When implementation reveals a better approach, update the spec in the same change.

Key rules from the specs: every user-visible action is a command and every state change is undoable (`specs/08-commands-and-undo.md`); single window, never open new tabs or windows (`specs/03-app-shell-and-navigation.md`); UI is built with `shad` and custom widgets in `wonky_ui` (`specs/02-design-system.md`); everything works in light and dark.

Expand Down
4 changes: 4 additions & 0 deletions packages/wonky_app/lib/l10n/wonky_app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,10 @@
"@navStorage": {
"description": "Left menu (Drive): the Storage page; storage meter label."
},
"navAnalytics": "Analytics",
"@navAnalytics": {
"description": "Left menu (Drive, admins only): the usage analytics page (specs/19)."
},
"navTrash": "Trash",
"newButton": "New",
"newFolderLabel": "Folder name",
Expand Down
1 change: 1 addition & 0 deletions packages/wonky_app/lib/l10n/wonky_app_en_XA.arb
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@
"navShared": "[Šĥåŕéð ŵîţĥ ɱé one t]",
"navStarred": "[Šţåŕŕéð one]",
"navStorage": "[Šţöŕåĝé one]",
"navAnalytics": "[Åñåļýţîçš one]",
"navTrash": "[Ţŕåšĥ on]",
"newButton": "[Ñéŵ on]",
"newFolderLabel": "[Ƒöļðéŕ ñåɱé one]",
Expand Down
16 changes: 12 additions & 4 deletions packages/wonky_app/lib/src/config/home_scope.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,26 +102,33 @@ enum HomeNavDestination {

/// Drive only: the Storage page (`/storage`).
storage,

/// Drive only, admins only: usage analytics (`/analytics`, specs/19).
analytics,
}

/// One entry of the left menu's navigation.
@immutable
class HomeNavItem {
const HomeNavItem(this.destination, this.route);
const HomeNavItem(this.destination, this.route, {this.adminOnly = false});

final HomeNavDestination destination;

/// The route relative to the app's base href, e.g. `/my-files`.
final String route;

/// Shown to admins only (the board's admin rule, specs/16 §9.1).
final bool adminOnly;

@override
bool operator ==(Object other) =>
other is HomeNavItem &&
other.destination == destination &&
other.route == route;
other.route == route &&
other.adminOnly == adminOnly;

@override
int get hashCode => Object.hash(destination, route);
int get hashCode => Object.hash(destination, route, adminOnly);
}

/// The navigation section of an app's left menu (`WonkyAppConfig.homeNav`,
Expand All @@ -145,7 +152,7 @@ class HomeNavSpec {
);

/// Wonky Drive: Home, My Drive (+ folder tree), Shared with me, Recent,
/// Starred, Trash, Storage.
/// Starred, Trash, Storage, and for admins Analytics.
static const drive = HomeNavSpec(
items: [
HomeNavItem(HomeNavDestination.home, '/'),
Expand All @@ -155,6 +162,7 @@ class HomeNavSpec {
HomeNavItem(HomeNavDestination.starred, '/starred'),
HomeNavItem(HomeNavDestination.trash, '/trash'),
HomeNavItem(HomeNavDestination.storage, '/storage'),
HomeNavItem(HomeNavDestination.analytics, '/analytics', adminOnly: true),
],
folderTree: true,
);
Expand Down
69 changes: 43 additions & 26 deletions packages/wonky_app/lib/src/drive/home_sidebar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:wonky_client/wonky_client.dart';
import 'package:wonky_core/wonky_core.dart' show InvocationSource;
import 'package:wonky_ui/wonky_ui.dart';

import '../board/board_hub.dart';
import '../bootstrap/app_urls.dart';
import '../branding/product_icon.dart';
import '../config/home_scope.dart';
Expand Down Expand Up @@ -60,16 +61,29 @@ class _HomeSidebarState extends State<HomeSidebar> {

ShellServices get s => widget.services;

/// Whether the menu has admin-only destinations (Drive's Analytics).
bool get _hasAdminItems => s.config.homeNav.items.any((i) => i.adminOnly);

@override
void initState() {
super.initState();
_sub = s.driveChanges.listen((c) {
if (c.item?.isFolder ?? true) _tree.refresh(c.item?.parentUuid?.uuid);
});
if (_hasAdminItems) {
final hub = BoardHub.of(s);
hub.me.addListener(_onAdminChanged);
unawaited(hub.loadMe());
}
}

void _onAdminChanged() {
if (mounted) setState(() {});
}

@override
void dispose() {
if (_hasAdminItems) BoardHub.of(s).me.removeListener(_onAdminChanged);
_sub?.cancel();
_tree.dispose();
super.dispose();
Expand Down Expand Up @@ -132,7 +146,7 @@ class _HomeSidebarState extends State<HomeSidebar> {
HomeNavDestination.starred => k == DriveLocationKind.starred,
HomeNavDestination.offline => k == DriveLocationKind.offline,
HomeNavDestination.trash => k == DriveLocationKind.trash,
HomeNavDestination.storage => false,
HomeNavDestination.storage || HomeNavDestination.analytics => false,
};
}

Expand Down Expand Up @@ -191,32 +205,34 @@ class _HomeSidebarState extends State<HomeSidebar> {
HomeNewButton(services: s, onNavigate: widget.onNavigate)
else
const SizedBox(height: 4),
for (final item in nav.items) ...[
navRow(item),
if (_treeOpen &&
nav.folderTree &&
item.destination == HomeNavDestination.myFiles)
SizedBox(
height: 240,
child: WonkyTreeView<DriveItemView>(
keyPrefix: 'drive.tree',
controller: _tree,
semanticLabel: l.folderTree,
onActivate: (n) => _go('/f/${n.id}'),
dragData: (n) =>
dragMoves && n.value.canManage ? DriveDragData([n.id]) : null,
canAcceptDrop: (data, target, pos) =>
dragMoves &&
data is DriveDragData &&
pos == WonkyTreeDropPosition.inside &&
target.value.canEdit &&
!data.uuids.contains(target.id),
onDrop: (data, target, pos) {
if (data is DriveDragData) dropIntoFolder(s, data, target.id);
},
for (final item in nav.items)
if (!item.adminOnly || BoardHub.of(s).isAdmin) ...[
navRow(item),
if (_treeOpen &&
nav.folderTree &&
item.destination == HomeNavDestination.myFiles)
SizedBox(
height: 240,
child: WonkyTreeView<DriveItemView>(
keyPrefix: 'drive.tree',
controller: _tree,
semanticLabel: l.folderTree,
onActivate: (n) => _go('/f/${n.id}'),
dragData: (n) => dragMoves && n.value.canManage
? DriveDragData([n.id])
: null,
canAcceptDrop: (data, target, pos) =>
dragMoves &&
data is DriveDragData &&
pos == WonkyTreeDropPosition.inside &&
target.value.canEdit &&
!data.uuids.contains(target.id),
onDrop: (data, target, pos) {
if (data is DriveDragData) dropIntoFolder(s, data, target.id);
},
),
),
),
],
],
];

return SizedBox(
Expand Down Expand Up @@ -264,6 +280,7 @@ class _HomeSidebarState extends State<HomeSidebar> {
HomeNavDestination.offline => (LucideIcons.cloudDownload, l.navOffline),
HomeNavDestination.trash => (LucideIcons.trash2, l.navTrash),
HomeNavDestination.storage => (LucideIcons.chartPie, l.navStorage),
HomeNavDestination.analytics => (LucideIcons.chartLine, l.navAnalytics),
};

/// Whether the home of [scope] has a *+ New* button: Drive and v1 homes;
Expand Down
6 changes: 6 additions & 0 deletions packages/wonky_app/lib/src/l10n/wonky_app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2326,6 +2326,12 @@ abstract class WonkyAppLocalizations {
/// **'Storage'**
String get navStorage;

/// Left menu (Drive, admins only): the usage analytics page (specs/19).
///
/// In en, this message translates to:
/// **'Analytics'**
String get navAnalytics;

/// No description provided for @navTrash.
///
/// In en, this message translates to:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,9 @@ class WonkyAppLocalizationsEn extends WonkyAppLocalizations {
@override
String get navStorage => 'Storage';

@override
String get navAnalytics => 'Analytics';

@override
String get navTrash => 'Trash';

Expand Down Expand Up @@ -6264,6 +6267,9 @@ class WonkyAppLocalizationsEnXa extends WonkyAppLocalizationsEn {
@override
String get navStorage => '[Šţöŕåĝé one]';

@override
String get navAnalytics => '[Åñåļýţîçš one]';

@override
String get navTrash => '[Ţŕåšĥ on]';

Expand Down
56 changes: 56 additions & 0 deletions packages/wonky_app/lib/src/shell/usage_ping.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'dart:async';

import 'package:clock/clock.dart';
import 'package:flutter/widgets.dart';
import 'package:wonky_client/wonky_client.dart';

import 'shell_services.dart';

/// Tells the server that the signed-in user is using this app, for the
/// usage analytics (specs/19 §2): at sign-in and whenever the app comes
/// back to the foreground, at most every [interval]. Best effort: a failed
/// ping is dropped.
class UsagePing with WidgetsBindingObserver {
UsagePing(this.services);

final ShellServices services;

/// The shortest time between two pings.
static const interval = Duration(minutes: 30);

DateTime? _last;
bool _started = false;

void start() {
if (_started) return;
_started = true;
WidgetsBinding.instance.addObserver(this);
unawaited(ping());
}

void stop() {
if (!_started) return;
_started = false;
WidgetsBinding.instance.removeObserver(this);
_last = null;
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) unawaited(ping());
}

Future<void> ping() async {
final c = services.client;
if (c == null || !services.auth.isSignedIn) return;
final now = clock.now();
final last = _last;
if (last != null && now.difference(last) < interval) return;
_last = now;
try {
await c.analytics.ping(UsageApp.values.byName(services.config.app.name));
} catch (_) {
// Best effort: analytics never disturb the app.
}
}
}
5 changes: 5 additions & 0 deletions packages/wonky_app/lib/src/shell/wonky_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import '../board/board_repository.dart';
import '../publications/publication_repository.dart';
import 'overlays/shell_overlays.dart';
import 'shell_services.dart';
import 'usage_ping.dart';
import '../undo/undo_service.dart';

/// The locale tag forced at build time with
Expand Down Expand Up @@ -169,6 +170,7 @@ class _WonkyShellState extends State<WonkyShell> {
bool _ready = false;
final Locale? _locale = forcedLocale();
StreamSubscription<UndoOutcome>? _undoSub;
late final UsagePing _usagePing = UsagePing(s);
late final AppLifecycleListener _lifecycle;

@override
Expand Down Expand Up @@ -310,10 +312,12 @@ class _WonkyShellState extends State<WonkyShell> {
);
}
_undoSub ??= s.undo.outcomes.listen((_) => s.registry.notifyStateChanged());
_usagePing.start();
await widget.config.onSignedIn(s);
}

Future<void> _beforeSignOut() async {
_usagePing.stop();
await widget.config.onSigningOut(s);
await s.supervisor.cancelAll();
final storage = s.storage;
Expand Down Expand Up @@ -350,6 +354,7 @@ class _WonkyShellState extends State<WonkyShell> {
void dispose() {
HardwareKeyboard.instance.removeHandler(_onUnfocusedKey);
_lifecycle.dispose();
_usagePing.stop();
_undoSub?.cancel();
s.auth.removeListener(_onAuth);
s.router.routerDelegate.removeListener(_onRoute);
Expand Down
3 changes: 2 additions & 1 deletion packages/wonky_app/test/drive/mobile_home_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,9 @@ void main() {
expect(key('home.footer.reportBug'), findsOneWidget);
expect(key('home.storage'), findsOneWidget);
final rows = [
// Admin-only entries (Drive's Analytics) are hidden here.
for (final item in h.services.config.homeNav.items)
'drive.nav.${item.destination.name}',
if (!item.adminOnly) 'drive.nav.${item.destination.name}',
for (final a in WonkyAppKind.values) 'home.app.${a.name}',
'home.footer.reportBug',
];
Expand Down
3 changes: 3 additions & 0 deletions specs/13-security-and-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
- (M14) Submissions send a document's text, images, fields and the submitter's display name to
the publication the user chose, after a consent dialog; publication signing secrets are stored
unencrypted because they must sign ([18 §11](18-publications-and-submissions.md)).
- Usage analytics ([19](19-usage-analytics.md)): one row per person, UTC day and app (ids, a
day, an app and an *edited* flag; never content), kept 400 days; shown to admins only, and
named in the privacy policy (*Usage data*).
- Export of all user data (*Settings › Account › Download your data*: zip of documents as
Markdown, spreadsheets as XLSX, comments as JSON) — P1.

Expand Down
1 change: 1 addition & 0 deletions specs/16-bugs-and-improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,7 @@ wonky:
re-verification, or if `validateGoogleAccountDetails` is overridden.
- Admin status is never stored. Removing the domain from the config removes admin rights at the
next request.
- The same rule decides who sees the usage analytics in Wonky Drive ([19](19-usage-analytics.md)).
- `board.me()` returns `{signedIn, isAdmin, blocked, activeClaims, maxActiveClaims,
claimBlockedUntil?, claimBlockedReason?, githubLogin?}` for the client.
It is used to show admin UI only; every admin endpoint checks again on the server.
Expand Down
Loading
Loading