Replaces the Material 3 screens with the Noo design system: warm neutrals, one violet accent, pill controls, Schibsted Grotesk/ Instrument Sans, Lucide icons, no gradients/shadows. - New lib/widgets/noo/ component kit (core, lists, files, media, nav, overlays) plus lib/theme/design_tokens.dart for the color/type/space/ radius/motion tokens. - Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and toolbar), every tab (Files/Offline, Photos, Favorites, Recent, Activity, Trash, Shares), Settings, the lock and login screens, the media viewer, search, the details/share sheets, and the share-upload and move/copy destination pickers. - Added a design canvas (linked from DESIGN_SYSTEM.md) covering the screens the spec didn't already describe, with matching Android and iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4. - Removed now-dead legacy widgets (media_grid_tile, swipeable_item, sync_status_badge, selectable_thumbnail) and updated architecture.md/styling.md/standards.md to describe the new structure and component/testing conventions. - Added widget tests for the noo/ component kit. This is a UI-only rework: no provider/model/service behavior changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
418 lines
15 KiB
Dart
418 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../models/saved_account.dart';
|
|
import '../providers/session_controller.dart';
|
|
import '../theme/design_tokens.dart';
|
|
import '../widgets/noo/core/noo_avatar.dart';
|
|
import '../widgets/noo/core/noo_button.dart';
|
|
import '../widgets/noo/nav/noo_top_bar.dart';
|
|
import '../widgets/noo/noo_layout.dart';
|
|
import '../widgets/noo/overlays/noo_text_field.dart';
|
|
import '../widgets/settings/settings_dialogs.dart';
|
|
import '../widgets/shell/shell_common.dart';
|
|
import 'login_webview_view.dart';
|
|
|
|
class LoginView extends StatefulWidget {
|
|
/// True when this is pushed from Settings ("Add account") on top of an
|
|
/// already-logged-in session, rather than shown as the app's root screen
|
|
/// with no account yet. Adds a top bar/back affordance and auto-pops once
|
|
/// the new account becomes active.
|
|
final bool isAddingAccount;
|
|
|
|
const LoginView({super.key, this.isAddingAccount = false});
|
|
|
|
@override
|
|
State<LoginView> createState() => _LoginViewState();
|
|
}
|
|
|
|
class _LoginViewState extends State<LoginView> {
|
|
final _urlController = TextEditingController();
|
|
String? _originalActiveAccountId;
|
|
String? _localError;
|
|
bool _popped = false;
|
|
bool _webViewPushed = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.isAddingAccount) {
|
|
_originalActiveAccountId = context.read<SessionController>().activeAccountId;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_urlController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleContinue() {
|
|
final url = _urlController.text.trim();
|
|
if (url.isEmpty) {
|
|
setState(() => _localError = 'Please enter your Nextcloud server address');
|
|
return;
|
|
}
|
|
setState(() => _localError = null);
|
|
FocusScope.of(context).unfocus();
|
|
context.read<SessionController>().startLoginFlow(url, addAccount: widget.isAddingAccount);
|
|
}
|
|
|
|
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
|
|
/// That can fail silently from the account's own perspective - most often
|
|
/// a stored app password that no longer works (revoked server-side, or
|
|
/// left over from before a since-fixed bug that deleted it too eagerly on
|
|
/// a plain network hiccup) - so this surfaces that as a SnackBar with a
|
|
/// silently failing every time - a delete button on the row itself (see
|
|
/// [_SavedAccountRow]) is the way out.
|
|
Future<void> _continueAsAccount(SavedAccount account) async {
|
|
final session = context.read<SessionController>();
|
|
final success = await session.switchAccount(account.id);
|
|
if (success || !mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text("Couldn't sign in as ${account.username}"),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Mirrors Settings' own account removal - same confirm/remove gate, just
|
|
/// reached from the login screen instead of the saved-accounts list.
|
|
Future<void> _confirmRemoveAccount(SavedAccount account) async {
|
|
await confirmRemoveAccount(
|
|
context,
|
|
context.read<SessionController>(),
|
|
accountId: account.id,
|
|
username: account.username,
|
|
host: serverHost(account.serverUrl),
|
|
isActive: false,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
final session = context.watch<SessionController>();
|
|
|
|
final isAwaitingBrowser = session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
|
|
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
|
|
|
|
// A new/refreshed account has just become active - pop back to
|
|
// Settings rather than leaving this form sitting on top of it.
|
|
if (widget.isAddingAccount &&
|
|
!_popped &&
|
|
session.loginFlowStatus == LoginFlowStatus.idle &&
|
|
session.activeAccountId != _originalActiveAccountId) {
|
|
_popped = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) Navigator.of(context).pop();
|
|
});
|
|
}
|
|
|
|
// Every login (first account or an additional one) shows its login
|
|
// page in LoginWebViewView, a real screen this app owns - see
|
|
// SessionController.startLoginFlow's doc comment for why. Push it the
|
|
// moment there's a URL to show; _webViewPushed resets once that route
|
|
// pops (cancelled or done) so a retry after cancelling pushes it again.
|
|
if (!_webViewPushed && isAwaitingBrowser && session.pendingLoginUrl != null) {
|
|
_webViewPushed = true;
|
|
final url = session.pendingLoginUrl!;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
if (!mounted) return;
|
|
// A plain MaterialPageRoute's animated slide/fade transition can
|
|
// leave this screen fully black after popping back off of it - a
|
|
// known Android WebView-as-PlatformView compositing issue with the
|
|
// Impeller renderer (the platform view's surface isn't always torn
|
|
// down in sync with an animated route transition). An instant,
|
|
// non-animated route sidesteps it entirely; the close (X) button
|
|
// already reads like a immediate action, not something that needs
|
|
// a slide, so no UX is lost.
|
|
await Navigator.of(context).push(
|
|
PageRouteBuilder(
|
|
pageBuilder: (_, _, _) => LoginWebViewView(url: url),
|
|
transitionDuration: Duration.zero,
|
|
reverseTransitionDuration: Duration.zero,
|
|
),
|
|
);
|
|
_webViewPushed = false;
|
|
});
|
|
}
|
|
|
|
final errorMessage =
|
|
_localError ?? (session.loginFlowStatus == LoginFlowStatus.error ? session.errorMessage : null);
|
|
|
|
final body = Scaffold(
|
|
backgroundColor: colors.bg,
|
|
appBar: widget.isAddingAccount
|
|
? NooTopBar(
|
|
style: NooLayout.navStyle(context),
|
|
title: 'Add account',
|
|
leading: const NooTopBarBack(),
|
|
)
|
|
: null,
|
|
body: SafeArea(
|
|
child: Center(
|
|
child: SingleChildScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 440),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// A logout keeps the account saved rather than
|
|
// deleting it, specifically so it can be resumed
|
|
// from here with one tap - no need to repeat
|
|
// Login Flow v2.
|
|
if (!widget.isAddingAccount && session.accounts.isNotEmpty) ...[
|
|
_SavedAccountsSection(
|
|
accounts: session.accounts,
|
|
onSelect: (account) => _continueAsAccount(account),
|
|
onRemove: (account) => _confirmRemoveAccount(account),
|
|
),
|
|
const SizedBox(height: 28),
|
|
Row(
|
|
children: [
|
|
Expanded(child: Divider(color: colors.line)),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Text('or', style: NooText.meta.copyWith(color: colors.fg3)),
|
|
),
|
|
Expanded(child: Divider(color: colors.line)),
|
|
],
|
|
),
|
|
const SizedBox(height: 28),
|
|
],
|
|
_ServerForm(
|
|
urlController: _urlController,
|
|
isLoading: isInitiating || session.isLoading || isAwaitingBrowser,
|
|
errorMessage: errorMessage,
|
|
onContinue: _handleContinue,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (!widget.isAddingAccount) return body;
|
|
|
|
// Backing out mid-flow (system back/swipe-back, not just the explicit
|
|
// Cancel button in LoginWebViewView) should cancel the pending login
|
|
// flow rather than leaving its poll timer running after this screen is
|
|
// gone - this view could never be popped before "add account" existed,
|
|
// so that case wasn't reachable until now.
|
|
return PopScope(
|
|
canPop: session.loginFlowStatus != LoginFlowStatus.awaitingBrowser,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (!didPop) session.cancelLoginFlow();
|
|
},
|
|
child: body,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ServerForm extends StatelessWidget {
|
|
final TextEditingController urlController;
|
|
final bool isLoading;
|
|
final String? errorMessage;
|
|
final VoidCallback onContinue;
|
|
|
|
const _ServerForm({
|
|
required this.urlController,
|
|
required this.isLoading,
|
|
required this.errorMessage,
|
|
required this.onContinue,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
|
|
return Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(
|
|
// Same monochrome-tinted treatment as the splash/lock screens
|
|
// (see main.dart's _SplashView) - the asset is a plain white
|
|
// silhouette on transparent, meant to be recolored rather than
|
|
// shown as-is. Used everywhere the app shows its own icon
|
|
// in-app, rather than the full-color launcher icon.
|
|
child: ColorFiltered(
|
|
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
|
|
child: Image.asset('assets/icon/app_icon_monochrome.png', width: 80, height: 80),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text('Noo', textAlign: TextAlign.center, style: NooText.largeTitle.copyWith(color: colors.fg1)),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Connect to your self-hosted server',
|
|
textAlign: TextAlign.center,
|
|
style: NooText.body.copyWith(color: colors.fg3),
|
|
),
|
|
const SizedBox(height: 36),
|
|
if (errorMessage != null)
|
|
Container(
|
|
margin: const EdgeInsets.only(bottom: 20),
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: colors.dangerSoft,
|
|
borderRadius: BorderRadius.circular(NooRadii.input),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(LucideIcons.circleAlert, color: colors.danger, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
errorMessage!,
|
|
style: NooText.body.copyWith(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: colors.danger,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
NooTextField(
|
|
controller: urlController,
|
|
placeholder: 'cloud.example.com',
|
|
leadingIcon: LucideIcons.server,
|
|
keyboardType: TextInputType.url,
|
|
onSubmitted: (_) => onContinue(),
|
|
),
|
|
const SizedBox(height: 28),
|
|
NooButton(
|
|
variant: NooButtonVariant.primary,
|
|
size: NooButtonSize.cta,
|
|
fullWidth: true,
|
|
disabled: isLoading,
|
|
onTap: onContinue,
|
|
child: Text(isLoading ? 'Connecting…' : 'Continue'),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
"You'll finish signing in on the page that opens. This app never sees your password.",
|
|
textAlign: TextAlign.center,
|
|
style: NooText.meta.copyWith(color: colors.fg3, fontSize: 11),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Saved accounts a logout left recoverable - tapping one resumes it via
|
|
/// [SessionController.switchAccount] instead of repeating Login Flow v2.
|
|
class _SavedAccountsSection extends StatelessWidget {
|
|
final List<SavedAccount> accounts;
|
|
final ValueChanged<SavedAccount> onSelect;
|
|
final ValueChanged<SavedAccount> onRemove;
|
|
|
|
const _SavedAccountsSection({required this.accounts, required this.onSelect, required this.onRemove});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
|
child: Text('Continue as', style: NooText.label.copyWith(color: colors.fg2)),
|
|
),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(color: colors.line),
|
|
child: Column(
|
|
children: [
|
|
for (var i = 0; i < accounts.length; i++) ...[
|
|
if (i > 0) const SizedBox(height: 1),
|
|
_SavedAccountRow(
|
|
account: accounts[i],
|
|
onTap: () => onSelect(accounts[i]),
|
|
onRemove: () => onRemove(accounts[i]),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SavedAccountRow extends StatelessWidget {
|
|
final SavedAccount account;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onRemove;
|
|
|
|
const _SavedAccountRow({required this.account, required this.onTap, required this.onRemove});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
|
|
return Material(
|
|
color: colors.surface,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
|
|
child: Row(
|
|
spacing: 12,
|
|
children: [
|
|
NooAvatar(initials: accountInitial(account.username), size: 36),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
spacing: 2,
|
|
children: [
|
|
Text(
|
|
account.username,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: NooText.bodyL.copyWith(
|
|
height: 1.1,
|
|
fontWeight: FontWeight.w500,
|
|
color: colors.fg1,
|
|
),
|
|
),
|
|
Text(
|
|
serverHost(account.serverUrl),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: NooText.meta.copyWith(color: colors.fg3),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
GestureDetector(
|
|
onTap: onRemove,
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
|
|
child: Icon(LucideIcons.x, size: 16, color: colors.fg2),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|