Merge branch 'revive-legacy-signin'

This commit is contained in:
Ming Ming 2024-10-19 01:32:18 +08:00
commit 4a1900b283
14 changed files with 1029 additions and 724 deletions

View file

@ -1501,6 +1501,10 @@
"homeTabMapBrowser": "Map",
"mapBrowserSetDefaultDateRangeButton": "Set as default",
"todayText": "Today",
"alternativeSignIn": "Alternative sign in with username and password",
"@alternativeSignIn": {
"description": "Sign in using username and password instead of the recommended Nextcloud login flow"
},
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": {

View file

@ -260,6 +260,7 @@
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText",
"alternativeSignIn",
"errorUnauthenticated",
"errorDisconnected",
"errorLocked",
@ -272,7 +273,8 @@
"cs": [
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"de": [
@ -292,7 +294,8 @@
"searchLandingPeopleListEmptyText2",
"mapBrowserDateRangeLabel",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"el": [
@ -440,12 +443,14 @@
"mapBrowserDateRangeCustom",
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"es": [
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"fi": [
@ -484,7 +489,8 @@
"mapBrowserDateRangeCustom",
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"fr": [
@ -523,7 +529,8 @@
"mapBrowserDateRangeCustom",
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"it": [
@ -567,7 +574,8 @@
"mapBrowserDateRangeCustom",
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"nl": [
@ -948,6 +956,7 @@
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText",
"alternativeSignIn",
"errorUnauthenticated",
"errorDisconnected",
"errorLocked",
@ -998,7 +1007,8 @@
"mapBrowserDateRangeCustom",
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"pt": [
@ -1057,7 +1067,8 @@
"mapBrowserDateRangeCustom",
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"ru": [
@ -1096,7 +1107,12 @@
"mapBrowserDateRangeCustom",
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"tr": [
"alternativeSignIn"
],
"zh": [
@ -1166,7 +1182,8 @@
"mapBrowserDateRangeCustom",
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
],
"zh_Hant": [
@ -1330,6 +1347,7 @@
"mapBrowserDateRangeCustom",
"homeTabMapBrowser",
"mapBrowserSetDefaultDateRangeButton",
"todayText"
"todayText",
"alternativeSignIn"
]
}

View file

@ -1,356 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/db/entity_converter.dart';
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/entity/pref_util.dart' as pref_util;
import 'package:nc_photos/help_utils.dart' as help_utils;
import 'package:nc_photos/legacy/connect.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/url_launcher_util.dart';
import 'package:nc_photos/widget/app_intermediate_circular_progress_indicator.dart';
import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/root_picker.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_collection/np_collection.dart';
import 'package:np_db/np_db.dart';
import 'package:np_platform_util/np_platform_util.dart';
import 'package:np_string/np_string.dart';
part 'sign_in.g.dart';
class SignIn extends StatefulWidget {
static const routeName = "/sign-in-legacy";
const SignIn({super.key});
@override
createState() => _SignInState();
}
@npLog
class _SignInState extends State<SignIn> {
@override
build(BuildContext context) {
return Scaffold(
body: Builder(builder: (context) => _buildContent(context)),
);
}
Widget _buildContent(BuildContext context) {
if (_isConnecting) {
return const Stack(
children: [
Positioned(
left: 0,
right: 0,
bottom: 64,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: AppIntermediateCircularProgressIndicator(),
),
),
),
],
);
} else {
return SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewportConstraints) {
return Form(
key: _formKey,
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: viewportConstraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Text(
L10n.global().signInHeaderText,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
),
Align(
alignment: Alignment.center,
child: Container(
constraints: BoxConstraints(
maxWidth:
Theme.of(context).widthLimitedContentMaxWidth,
),
padding: const EdgeInsets.symmetric(horizontal: 32),
child: _buildForm(context),
),
),
Container(
alignment: AlignmentDirectional.centerStart,
constraints: BoxConstraints(
maxWidth:
Theme.of(context).widthLimitedContentMaxWidth,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: InkWell(
onTap: () {
launch(help_utils.twoFactorAuthUrl);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.help_outline, size: 16),
const SizedBox(width: 4),
Expanded(
child:
Text(L10n.global().signIn2faHintText),
),
],
),
),
),
),
if (getRawPlatform() != NpPlatform.web)
Expanded(child: Container()),
Container(
constraints: BoxConstraints(
maxWidth:
Theme.of(context).widthLimitedContentMaxWidth,
),
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!ModalRoute.of(context)!.isFirst)
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(MaterialLocalizations.of(context)
.cancelButtonLabel),
)
else
Container(),
ElevatedButton(
onPressed: () {
if (_formKey.currentState?.validate() ==
true) {
_connect();
}
},
child: Text(L10n.global().connectButtonLabel),
),
],
),
),
],
),
),
),
),
);
},
),
);
}
}
Widget _buildForm(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Icon(
Icons.cloud,
color: Theme.of(context).colorScheme.primary,
size: 72,
),
),
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 64,
child: DropdownButtonHideUnderline(
child: DropdownButtonFormField<_Scheme>(
value: _scheme,
items: [_Scheme.http, _Scheme.https]
.map((e) => DropdownMenuItem<_Scheme>(
value: e,
child: Text(e.toValueString()),
))
.toList(),
onChanged: (newValue) {
setState(() {
_scheme = newValue!;
});
},
onSaved: (value) {
_formValue.scheme = value!.toValueString();
},
),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Text("://"),
),
Expanded(
child: TextFormField(
decoration: InputDecoration(
hintText: L10n.global().serverAddressInputHint,
),
keyboardType: TextInputType.url,
validator: (value) {
if (value!.trim().trimRightAny("/").isEmpty) {
return L10n.global().serverAddressInputInvalidEmpty;
}
return null;
},
onSaved: (value) {
_formValue.address = value!.trim().trimRightAny("/");
},
),
),
],
),
const SizedBox(height: 8),
TextFormField(
decoration: InputDecoration(
hintText: L10n.global().usernameInputHint,
),
validator: (value) {
if (value!.trim().isEmpty) {
return L10n.global().usernameInputInvalidEmpty;
}
return null;
},
onSaved: (value) {
_formValue.username = value!;
},
),
const SizedBox(height: 8),
TextFormField(
decoration: InputDecoration(
hintText: L10n.global().passwordInputHint,
),
obscureText: true,
validator: (value) {
if (value!.trim().isEmpty) {
return L10n.global().passwordInputInvalidEmpty;
}
return null;
},
onSaved: (value) {
_formValue.password = value!;
},
),
],
);
}
Future<void> _connect() async {
_formKey.currentState!.save();
Account? account = Account(
id: Account.newId(),
scheme: _formValue.scheme,
address: _formValue.address,
userId: _formValue.username.toCi(),
username2: _formValue.username,
password: _formValue.password,
roots: [""],
);
_log.info("[_connect] Try connecting with account: $account");
account = await Navigator.pushNamed<Account>(context, Connect.routeName,
arguments: ConnectArguments(account));
if (account == null) {
// connection failed
return;
}
account = await Navigator.pushNamed<Account>(context, RootPicker.routeName,
arguments: RootPickerArguments(account));
if (account == null) {
// ???
return;
}
// we've got a good account
setState(() {
_isConnecting = true;
});
try {
await _persistAccount(account);
unawaited(
Navigator.pushNamedAndRemoveUntil(
context, Home.routeName, (route) => false,
arguments: HomeArguments(account)),
);
} catch (_) {
setState(() {
_isConnecting = false;
});
rethrow;
}
}
Future<void> _persistAccount(Account account) async {
await context.read<NpDb>().addAccounts([account.toDb()]);
// only signing in with app password would trigger distinct
final accounts = (Pref().getAccounts3Or([])..add(account)).distinct();
try {
AccountPref.setGlobalInstance(
account, await pref_util.loadAccountPref(account));
} catch (e, stackTrace) {
_log.shout("[_connect] Failed reading pref for account: $account", e,
stackTrace);
}
final prefController = context.read<PrefController>();
unawaited(Pref().setAccounts3(accounts));
unawaited(prefController.setCurrentAccountIndex(accounts.indexOf(account)));
}
final _formKey = GlobalKey<FormState>();
var _scheme = _Scheme.https;
var _isConnecting = false;
final _formValue = _FormValue();
}
enum _Scheme {
http,
https,
}
extension on _Scheme {
String toValueString() {
switch (this) {
case _Scheme.http:
return "http";
case _Scheme.https:
return "https";
default:
throw StateError("Unknown value: $this");
}
}
}
class _FormValue {
late String scheme;
late String address;
late String username;
late String password;
}

View file

@ -1,14 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sign_in.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$_SignInStateNpLog on _SignInState {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("legacy.sign_in._SignInState");
}

View file

@ -6,7 +6,6 @@ import 'package:clock/clock.dart';
import 'package:copy_with/copy_with.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart';
import 'package:nc_photos/account.dart';
@ -16,7 +15,6 @@ import 'package:nc_photos/bloc_util.dart';
import 'package:nc_photos/controller/account_controller.dart';
import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/db/entity_converter.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/entity/server_status.dart';
import 'package:nc_photos/exception_event.dart';
@ -49,7 +47,6 @@ class AccountPickerDialog extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => _Bloc(
container: KiwiContainer().resolve(),
accountController: context.read(),
prefController: context.read(),
db: context.read(),

View file

@ -3,13 +3,11 @@ part of '../account_picker_dialog.dart';
@npLog
class _Bloc extends Bloc<_Event, _State> with BlocLogger {
_Bloc({
required DiContainer container,
required this.accountController,
required this.prefController,
required this.db,
}) : _c = container,
super(_State.init(
accounts: container.pref.getAccounts3Or([]),
}) : super(_State.init(
accounts: prefController.accountsValue,
)) {
on<_ToggleDropdown>(_onToggleDropdown);
on<_SwitchAccount>(_onSwitchAccount);
@ -59,7 +57,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
));
try {
await _prefLock.protect(() async {
final accounts = _c.pref.getAccounts3()!;
final accounts = prefController.accountsValue;
final currentAccount =
accounts[prefController.currentAccountIndexValue!];
accounts.remove(ev.account);
@ -74,7 +72,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
_log.shout("[_onDeleteAccount] Failed while removing account pref", e,
stackTrace);
}
await Pref().setAccounts3(accounts);
await prefController.setAccounts(accounts);
await prefController.setCurrentAccountIndex(newAccountIndex);
// check if the same account (server + userId) still exists in known
@ -109,7 +107,6 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
}
}
final DiContainer _c;
final AccountController accountController;
final PrefController prefController;
final NpDb db;

View file

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/widget/measure.dart';
class ExpandableContainer extends StatefulWidget {
const ExpandableContainer({
super.key,
required this.isShow,
required this.child,
});
@override
State<StatefulWidget> createState() => ExpandableContainerState();
final bool isShow;
final Widget child;
}
class ExpandableContainerState extends State<ExpandableContainer>
with TickerProviderStateMixin {
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: k.animationDurationNormal,
vsync: this,
value: 0,
);
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant ExpandableContainer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isShow != widget.isShow) {
if (widget.isShow) {
_animationController.animateTo(1);
} else {
_animationController.animateBack(0);
}
}
}
@override
Widget build(BuildContext context) {
return MatrixTransition(
animation: _animation,
onTransform: (animationValue) => Matrix4.identity()
..translate(0.0, -(_size.height / 2) * (1 - animationValue), 0.0)
..scale(1.0, animationValue, 1.0),
child: MeasureSize(
onChange: (size) => setState(() {
_size = size;
}),
child: widget.child,
),
);
}
late AnimationController _animationController;
late Animation<double> _animation;
var _size = Size.zero;
}

View file

@ -17,7 +17,6 @@ import 'package:nc_photos/controller/trusted_cert_controller.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/legacy/connect.dart' as legacy;
import 'package:nc_photos/legacy/sign_in.dart' as legacy;
import 'package:nc_photos/mobile/self_signed_cert_manager.dart';
import 'package:nc_photos/navigation_manager.dart';
import 'package:nc_photos/protected_page_handler.dart';
@ -207,9 +206,6 @@ class _WrappedAppState extends State<_WrappedApp>
Splash.routeName: () => MaterialPageRoute(
builder: (context) => const Splash(),
),
legacy.SignIn.routeName: () => MaterialPageRoute(
builder: (context) => const legacy.SignIn(),
),
CollectionPicker.routeName: CollectionPicker.buildRoute,
LanguageSettings.routeName: LanguageSettings.buildRoute,
PeopleBrowser.routeName: PeopleBrowser.buildRoute,

View file

@ -1,41 +1,67 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:copy_with/copy_with.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc_util.dart';
import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/db/entity_converter.dart';
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/entity/pref_util.dart' as pref_util;
import 'package:nc_photos/legacy/sign_in.dart' as legacy;
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/legacy/connect.dart' as legacy;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/app_intermediate_circular_progress_indicator.dart';
import 'package:nc_photos/widget/connect.dart';
import 'package:nc_photos/widget/expandable_container.dart';
import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/page_visibility_mixin.dart';
import 'package:nc_photos/widget/root_picker.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_collection/np_collection.dart';
import 'package:np_db/np_db.dart';
import 'package:np_string/np_string.dart';
import 'package:to_string/to_string.dart';
part 'sign_in.g.dart';
part 'sign_in/bloc.dart';
part 'sign_in/state_event.dart';
part 'sign_in/type.dart';
part 'sign_in/view.dart';
class SignIn extends StatefulWidget {
class SignIn extends StatelessWidget {
static const routeName = "/sign-in";
const SignIn({super.key});
@override
createState() => _SignInState();
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => _Bloc(
npDb: context.read(),
prefController: context.read(),
),
child: const _WrappedSignIn(),
);
}
}
class _WrappedSignIn extends StatefulWidget {
const _WrappedSignIn();
@override
State<StatefulWidget> createState() => _WrappedSignInState();
}
@npLog
class _SignInState extends State<SignIn> {
class _WrappedSignInState extends State<_WrappedSignIn>
with RouteAware, PageVisibilityMixin {
@override
build(BuildContext context) {
Widget build(BuildContext context) {
return Theme(
data: buildDarkTheme(context).copyWith(
scaffoldBackgroundColor: Colors.transparent,
@ -51,346 +77,126 @@ class _SignInState extends State<SignIn> {
child: Stack(
fit: StackFit.expand,
children: [
const _SingInBackground(),
const _Background(),
Scaffold(
body: Builder(builder: (context) => _buildContent(context)),
body: MultiBlocListener(
listeners: [
_BlocListenerT(
selector: (state) => state.connectArg,
listener: (context, connectArg) {
if (connectArg == null) {
return;
}
if (connectArg.username != null &&
connectArg.password != null) {
_onLegacyConnect(context, connectArg);
} else {
final uri = Uri.parse(
"${connectArg.scheme}://${connectArg.address}");
_onConnect(context, uri);
}
},
),
_BlocListenerT(
selector: (state) => state.isCompleted,
listener: (context, isCompleted) {
if (isCompleted) {
Navigator.pushNamedAndRemoveUntil(
context,
Home.routeName,
(route) => false,
arguments:
HomeArguments(context.state.connectedAccount!),
);
}
},
),
_BlocListenerT(
selector: (state) => state.error,
listener: (context, error) {
if (error != null && isPageVisible()) {
SnackBarManager().showSnackBarForException(error.error);
}
},
),
],
child: _BlocSelector(
selector: (state) => state.isConnecting,
builder: (context, isConnecting) =>
isConnecting ? const _ConnectingBody() : const _Body(),
),
),
),
],
),
);
}
Widget _buildContent(BuildContext context) {
if (_isConnecting) {
return const Stack(
children: [
Positioned(
left: 0,
right: 0,
bottom: 64,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: AppIntermediateCircularProgressIndicator(),
),
),
),
],
);
} else {
return Form(
key: _formKey,
child: Center(
child: Container(
constraints: BoxConstraints(
maxWidth: Theme.of(context).widthLimitedContentMaxWidth,
),
child: Column(
children: [
Expanded(
child: Center(
child: SingleChildScrollView(
child: _SignInBody(
onSchemeSaved: (scheme) {
_formValue.scheme = scheme;
},
onServerUrlSaved: (url) {
_formValue.address = url;
},
),
),
),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!ModalRoute.of(context)!.isFirst)
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(MaterialLocalizations.of(context)
.cancelButtonLabel),
)
else
Container(),
ElevatedButton(
onPressed: () {
if (_formKey.currentState?.validate() == true) {
_connect();
}
},
child: Text(L10n.global().connectButtonLabel),
),
],
),
),
],
),
),
),
);
}
}
Future<void> _connect() async {
_formKey.currentState!.save();
Uri url = Uri.parse("${_formValue.scheme}://${_formValue.address}");
_log.info("[_connect] Try connecting with url: $url");
Account? account = await Navigator.pushNamed<Account>(
context, Connect.routeName,
arguments: ConnectArguments(url));
Future<void> _onConnect(BuildContext context, Uri connectUri) async {
var account = await Navigator.pushNamed<Account>(
context,
Connect.routeName,
arguments: ConnectArguments(connectUri),
);
if (account == null) {
// connection failed
return;
}
account = await Navigator.pushNamed<Account>(context, RootPicker.routeName,
arguments: RootPickerArguments(account));
account = await Navigator.pushNamed<Account>(
context,
RootPicker.routeName,
arguments: RootPickerArguments(account),
);
if (account == null) {
// ???
return;
}
// we've got a good account
setState(() {
_isConnecting = true;
});
try {
await _persistAccount(account);
unawaited(
Navigator.pushNamedAndRemoveUntil(
context, Home.routeName, (route) => false,
arguments: HomeArguments(account)),
);
} catch (_) {
setState(() {
_isConnecting = false;
});
rethrow;
context.addEvent(_SetConnectedAccount(account));
}
Future<void> _onLegacyConnect(BuildContext context, _ConnectArg arg) async {
Account? account = Account(
id: Account.newId(),
scheme: arg.scheme,
address: arg.address,
userId: arg.username!.toCi(),
username2: arg.username!,
password: arg.password!,
roots: [""],
);
_log.info("[_onLegacyConnect] Try connecting with account: $account");
account = await Navigator.pushNamed<Account>(
context,
legacy.Connect.routeName,
arguments: legacy.ConnectArguments(account),
);
if (account == null) {
// connection failed
return;
}
}
Future<void> _persistAccount(Account account) async {
await context.read<NpDb>().addAccounts([account.toDb()]);
// only signing in with app password would trigger distinct
final accounts = (Pref().getAccounts3Or([])..add(account)).distinct();
try {
AccountPref.setGlobalInstance(
account, await pref_util.loadAccountPref(account));
} catch (e, stackTrace) {
_log.shout("[_connect] Failed reading pref for account: $account", e,
stackTrace);
account = await Navigator.pushNamed<Account>(
context,
RootPicker.routeName,
arguments: RootPickerArguments(account),
);
if (account == null) {
// ???
return;
}
final prefController = context.read<PrefController>();
unawaited(Pref().setAccounts3(accounts));
unawaited(prefController.setCurrentAccountIndex(accounts.indexOf(account)));
}
final _formKey = GlobalKey<FormState>();
var _isConnecting = false;
final _formValue = _FormValue();
}
/// A nice background that matches Nextcloud without breaking any copyright law
class _SingInBackground extends StatelessWidget {
const _SingInBackground();
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: Theme.of(context).nextcloudBlue),
const Positioned(
bottom: 60,
left: -200,
child: Opacity(
opacity: .22,
child: Icon(
Icons.circle_outlined,
color: Colors.white,
size: 340,
),
),
),
const Positioned(
top: -120,
left: -180,
right: 0,
child: Opacity(
opacity: .1,
child: Icon(
Icons.circle_outlined,
color: Colors.white,
size: 620,
),
),
),
const Positioned(
bottom: -50,
right: -120,
child: Opacity(
opacity: .27,
child: Icon(
Icons.circle_outlined,
color: Colors.white,
size: 400,
),
),
),
],
);
// we've got a good account
context.addEvent(_SetConnectedAccount(account));
}
}
class _SignInBody extends StatelessWidget {
const _SignInBody({
this.onSchemeSaved,
this.onServerUrlSaved,
});
// typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
// typedef _BlocListener = BlocListener<_Bloc, _State>;
typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
typedef _Emitter = Emitter<_State>;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
L10n.global().signInHeaderText2,
style: Theme.of(context).textTheme.displayLarge!.copyWith(
color: Colors.white,
fontWeight: FontWeight.w100,
),
),
const SizedBox(height: 16),
Row(
children: [
SizedBox(
width: 64,
child: _SchemeDropdown(
onSaved: onSchemeSaved,
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Text("://"),
),
Expanded(
child: _ServerUrlInput(
onSaved: onServerUrlSaved,
),
),
],
),
if (kDebugMode) ...[
const SizedBox(height: 8),
InkWell(
onTap: () {
Navigator.pushReplacementNamed(
context, legacy.SignIn.routeName);
},
child: const Text(
"Legacy sign in",
style: TextStyle(decoration: TextDecoration.underline),
),
),
],
],
),
);
}
final void Function(String scheme)? onSchemeSaved;
final void Function(String url)? onServerUrlSaved;
}
enum _Scheme {
http,
https;
String toValueString() {
switch (this) {
case http:
return "http";
case https:
return "https";
}
}
}
class _SchemeDropdown extends StatefulWidget {
const _SchemeDropdown({
this.onSaved,
});
@override
State<StatefulWidget> createState() => _SchemeDropdownState();
final void Function(String scheme)? onSaved;
}
class _SchemeDropdownState extends State<_SchemeDropdown> {
@override
Widget build(BuildContext context) {
return DropdownButtonHideUnderline(
child: DropdownButtonFormField<_Scheme>(
value: _scheme,
items: _Scheme.values
.map((e) => DropdownMenuItem<_Scheme>(
value: e,
child: Text(e.toValueString()),
))
.toList(),
onChanged: (newValue) {
setState(() {
_scheme = newValue!;
});
},
onSaved: (value) {
widget.onSaved?.call(value!.toValueString());
},
),
);
}
var _scheme = _Scheme.https;
}
class _ServerUrlInput extends StatelessWidget {
const _ServerUrlInput({
this.onSaved,
});
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: InputDecoration(
hintText: L10n.global().serverAddressInputHint,
),
keyboardType: TextInputType.url,
validator: (value) {
if (value!.trim().trimRightAny("/").isEmpty) {
return L10n.global().serverAddressInputInvalidEmpty;
}
return null;
},
onSaved: (value) {
onSaved?.call(value!.trim().trimRightAny("/"));
},
);
}
final void Function(String url)? onSaved;
}
class _FormValue {
late String scheme;
late String address;
extension on BuildContext {
_Bloc get bloc => read<_Bloc>();
_State get state => bloc.state;
void addEvent(_Event event) => bloc.add(event);
}

View file

@ -2,13 +2,169 @@
part of 'sign_in.dart';
// **************************************************************************
// CopyWithLintRuleGenerator
// **************************************************************************
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class $_StateCopyWithWorker {
_State call(
{_Scheme? scheme,
String? serverUrl,
String? username,
String? password,
bool? shouldObscurePassword,
_ConnectArg? connectArg,
Account? connectedAccount,
bool? isConnecting,
bool? isCompleted,
bool? isAltMode,
ExceptionEvent? error});
}
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
_$_StateCopyWithWorkerImpl(this.that);
@override
_State call(
{dynamic scheme,
dynamic serverUrl,
dynamic username,
dynamic password,
dynamic shouldObscurePassword,
dynamic connectArg = copyWithNull,
dynamic connectedAccount = copyWithNull,
dynamic isConnecting,
dynamic isCompleted,
dynamic isAltMode,
dynamic error = copyWithNull}) {
return _State(
scheme: scheme as _Scheme? ?? that.scheme,
serverUrl: serverUrl as String? ?? that.serverUrl,
username: username as String? ?? that.username,
password: password as String? ?? that.password,
shouldObscurePassword:
shouldObscurePassword as bool? ?? that.shouldObscurePassword,
connectArg: connectArg == copyWithNull
? that.connectArg
: connectArg as _ConnectArg?,
connectedAccount: connectedAccount == copyWithNull
? that.connectedAccount
: connectedAccount as Account?,
isConnecting: isConnecting as bool? ?? that.isConnecting,
isCompleted: isCompleted as bool? ?? that.isCompleted,
isAltMode: isAltMode as bool? ?? that.isAltMode,
error: error == copyWithNull ? that.error : error as ExceptionEvent?);
}
final _State that;
}
extension $_StateCopyWith on _State {
$_StateCopyWithWorker get copyWith => _$copyWith;
$_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this);
}
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$_SignInStateNpLog on _SignInState {
extension _$_WrappedSignInStateNpLog on _WrappedSignInState {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.sign_in._SignInState");
static final log = Logger("widget.sign_in._WrappedSignInState");
}
extension _$_BlocNpLog on _Bloc {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.sign_in._Bloc");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$_StateToString on _State {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_State {scheme: ${scheme.name}, serverUrl: $serverUrl, username: $username, password: $password, shouldObscurePassword: $shouldObscurePassword, connectArg: $connectArg, connectedAccount: $connectedAccount, isConnecting: $isConnecting, isCompleted: $isCompleted, isAltMode: $isAltMode, error: $error}";
}
}
extension _$_SetSchemeToString on _SetScheme {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetScheme {value: ${value.name}}";
}
}
extension _$_SetServerUrlToString on _SetServerUrl {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetServerUrl {value: $value}";
}
}
extension _$_ConnectToString on _Connect {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_Connect {}";
}
}
extension _$_SetConnectedAccountToString on _SetConnectedAccount {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetConnectedAccount {value: $value}";
}
}
extension _$_SetAltModeToString on _SetAltMode {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetAltMode {value: $value}";
}
}
extension _$_SetUsernameToString on _SetUsername {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetUsername {value: $value}";
}
}
extension _$_SetPasswordToString on _SetPassword {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetPassword {value: $value}";
}
}
extension _$_SetObscurePasswordToString on _SetObscurePassword {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetObscurePassword {value: $value}";
}
}
extension _$_SetErrorToString on _SetError {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetError {error: $error, stackTrace: $stackTrace}";
}
}
extension _$_ConnectArgToString on _ConnectArg {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_ConnectArg {scheme: $scheme, address: $address, username: $username, password: $password}";
}
}

View file

@ -0,0 +1,131 @@
part of '../sign_in.dart';
@npLog
class _Bloc extends Bloc<_Event, _State> with BlocLogger {
_Bloc({
required this.npDb,
required this.prefController,
}) : super(_State.init()) {
on<_SetScheme>(_onSetScheme);
on<_SetServerUrl>(_onSetServerUrl);
on<_Connect>(_onConnect);
on<_SetConnectedAccount>(_onSetConnectedAccount);
on<_SetAltMode>(_onSetAltMode);
on<_SetUsername>(_onSetUsername);
on<_SetPassword>(_onSetPassword);
on<_SetObscurePassword>(_onSetObscurePassword);
on<_SetError>(_onSetError);
}
@override
String get tag => _log.fullName;
@override
void onError(Object error, StackTrace stackTrace) {
// we need this to prevent onError being triggered recursively
if (!isClosed && !_isHandlingError) {
_isHandlingError = true;
try {
add(_SetError(error, stackTrace));
} catch (_) {}
_isHandlingError = false;
}
super.onError(error, stackTrace);
}
void _onSetScheme(_SetScheme ev, _Emitter emit) {
_log.info(ev);
emit(state.copyWith(scheme: ev.value));
}
void _onSetServerUrl(_SetServerUrl ev, _Emitter emit) {
_log.info(ev);
emit(state.copyWith(serverUrl: ev.value));
}
void _onConnect(_Connect ev, _Emitter emit) {
_log.info(ev);
final scheme = state.scheme.toValueString();
final serverUrl = state.serverUrl.trim().trimRightAny("/");
final _ConnectArg arg;
if (!state.isAltMode) {
arg = _ConnectArg(
scheme: scheme,
address: serverUrl,
);
} else {
arg = _ConnectArg(
scheme: scheme,
address: serverUrl,
username: state.username,
password: state.password,
);
}
_log.info("[_onConnect] Try connecting: $arg");
emit(state.copyWith(connectArg: arg));
}
Future<void> _onSetConnectedAccount(
_SetConnectedAccount ev, _Emitter emit) async {
_log.info(ev);
emit(state.copyWith(isConnecting: true));
try {
await _persistAccount(ev.value);
emit(state.copyWith(
isCompleted: true,
connectedAccount: ev.value,
));
} catch (_) {
emit(state.copyWith(isConnecting: false));
rethrow;
}
}
void _onSetAltMode(_SetAltMode ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(isAltMode: ev.value));
}
void _onSetUsername(_SetUsername ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(username: ev.value));
}
void _onSetPassword(_SetPassword ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(password: ev.value));
}
void _onSetObscurePassword(_SetObscurePassword ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(shouldObscurePassword: ev.value));
}
void _onSetError(_SetError ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace)));
}
Future<void> _persistAccount(Account account) async {
await npDb.addAccounts([account.toDb()]);
// only signing in with app password would trigger distinct
final accounts = prefController.accountsValue.added(account).distinct();
try {
AccountPref.setGlobalInstance(
account,
await pref_util.loadAccountPref(account),
);
} catch (e, stackTrace) {
_log.shout("[_persistAccount] Failed reading pref for account: $account",
e, stackTrace);
}
unawaited(prefController.setAccounts(accounts));
unawaited(prefController.setCurrentAccountIndex(accounts.indexOf(account)));
}
final NpDb npDb;
final PrefController prefController;
var _isHandlingError = false;
}

View file

@ -0,0 +1,138 @@
part of '../sign_in.dart';
@genCopyWith
@toString
class _State {
const _State({
required this.scheme,
required this.serverUrl,
required this.username,
required this.password,
required this.shouldObscurePassword,
this.connectArg,
this.connectedAccount,
required this.isConnecting,
required this.isCompleted,
required this.isAltMode,
this.error,
});
factory _State.init() => const _State(
scheme: _Scheme.https,
serverUrl: "",
username: "",
password: "",
shouldObscurePassword: true,
isConnecting: false,
isCompleted: false,
isAltMode: false,
);
@override
String toString() => _$toString();
final _Scheme scheme;
final String serverUrl;
final String username;
final String password;
final bool shouldObscurePassword;
final _ConnectArg? connectArg;
final Account? connectedAccount;
final bool isConnecting;
final bool isCompleted;
final bool isAltMode;
final ExceptionEvent? error;
}
abstract class _Event {}
@toString
class _SetScheme implements _Event {
const _SetScheme(this.value);
@override
String toString() => _$toString();
final _Scheme value;
}
@toString
class _SetServerUrl implements _Event {
const _SetServerUrl(this.value);
@override
String toString() => _$toString();
final String value;
}
@toString
class _Connect implements _Event {
const _Connect();
@override
String toString() => _$toString();
}
@toString
class _SetConnectedAccount implements _Event {
const _SetConnectedAccount(this.value);
@override
String toString() => _$toString();
final Account value;
}
@toString
class _SetAltMode implements _Event {
const _SetAltMode(this.value);
@override
String toString() => _$toString();
final bool value;
}
@toString
class _SetUsername implements _Event {
const _SetUsername(this.value);
@override
String toString() => _$toString();
final String value;
}
@toString
class _SetPassword implements _Event {
const _SetPassword(this.value);
@override
String toString() => _$toString();
final String value;
}
@toString
class _SetObscurePassword implements _Event {
const _SetObscurePassword(this.value);
@override
String toString() => _$toString();
final bool value;
}
@toString
class _SetError implements _Event {
const _SetError(this.error, [this.stackTrace]);
@override
String toString() => _$toString();
final Object error;
final StackTrace? stackTrace;
}

View file

@ -0,0 +1,34 @@
part of '../sign_in.dart';
enum _Scheme {
http,
https;
String toValueString() {
switch (this) {
case http:
return "http";
case https:
return "https";
}
}
}
@toString
class _ConnectArg {
const _ConnectArg({
required this.scheme,
required this.address,
this.username,
this.password,
});
@override
String toString() => _$toString();
final String scheme;
final String address;
final String? username;
final String? password;
}

View file

@ -0,0 +1,326 @@
part of '../sign_in.dart';
/// A nice background that matches Nextcloud without breaking any copyright law
class _Background extends StatelessWidget {
const _Background();
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: Theme.of(context).nextcloudBlue),
const Positioned(
bottom: 60,
left: -200,
child: Opacity(
opacity: .22,
child: Icon(
Icons.circle_outlined,
color: Colors.white,
size: 340,
),
),
),
const Positioned(
top: -120,
left: -180,
right: 0,
child: Opacity(
opacity: .1,
child: Icon(
Icons.circle_outlined,
color: Colors.white,
size: 620,
),
),
),
const Positioned(
bottom: -50,
right: -120,
child: Opacity(
opacity: .27,
child: Icon(
Icons.circle_outlined,
color: Colors.white,
size: 400,
),
),
),
],
);
}
}
class _ConnectingBody extends StatelessWidget {
const _ConnectingBody();
@override
Widget build(BuildContext context) {
return const Stack(
children: [
Positioned(
left: 0,
right: 0,
bottom: 64,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: AppIntermediateCircularProgressIndicator(),
),
),
),
],
);
}
}
class _Body extends StatefulWidget {
const _Body();
@override
State<StatefulWidget> createState() => _BodyState();
}
class _BodyState extends State<_Body> {
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Center(
child: Container(
constraints: BoxConstraints(
maxWidth: Theme.of(context).widthLimitedContentMaxWidth,
),
child: Column(
children: [
const Expanded(
child: Center(
child: SingleChildScrollView(
child: _SignInBody(),
),
),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!ModalRoute.of(context)!.isFirst)
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(MaterialLocalizations.of(context)
.cancelButtonLabel),
)
else
Container(),
ElevatedButton(
onPressed: () {
if (_formKey.currentState?.validate() == true) {
context.addEvent(const _Connect());
}
},
child: Text(L10n.global().connectButtonLabel),
),
],
),
),
],
),
),
),
);
}
final _formKey = GlobalKey<FormState>();
}
class _SignInBody extends StatelessWidget {
const _SignInBody();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
L10n.global().signInHeaderText2,
style: Theme.of(context).textTheme.displayLarge!.copyWith(
color: Colors.white,
fontWeight: FontWeight.w100,
),
),
const SizedBox(height: 16),
const Row(
children: [
SizedBox(
width: 64,
child: _SchemeDropdown(),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Text("://"),
),
Expanded(
child: _ServerUrlInput(),
),
],
),
const SizedBox(height: 8),
Stack(
children: [
_BlocSelector(
selector: (state) => state.isAltMode,
builder: (context, isAltMode) => ExpandableContainer(
isShow: isAltMode,
child: const _LegacySignInForm(),
),
),
_BlocSelector(
selector: (state) => state.isAltMode,
builder: (context, isAltMode) => Visibility(
visible: !isAltMode,
child: InkWell(
onTap: () {
context.addEvent(const _SetAltMode(true));
},
child: Text(
L10n.global().alternativeSignIn,
style: const TextStyle(
decoration: TextDecoration.underline,
),
),
),
),
),
],
),
],
),
);
}
}
class _SchemeDropdown extends StatelessWidget {
const _SchemeDropdown();
@override
Widget build(BuildContext context) {
return DropdownButtonHideUnderline(
child: _BlocSelector(
selector: (state) => state.scheme,
builder: (context, scheme) => DropdownButtonFormField<_Scheme>(
value: scheme,
items: _Scheme.values
.map((e) => DropdownMenuItem<_Scheme>(
value: e,
child: Text(e.toValueString()),
))
.toList(),
onChanged: (value) {
if (value != null) {
context.addEvent(_SetScheme(value));
}
},
),
),
);
}
}
class _ServerUrlInput extends StatelessWidget {
const _ServerUrlInput();
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: InputDecoration(
hintText: L10n.global().serverAddressInputHint,
),
keyboardType: TextInputType.url,
validator: (value) {
if (value!.trim().trimRightAny("/").isEmpty) {
return L10n.global().serverAddressInputInvalidEmpty;
}
return null;
},
onChanged: (value) {
context.addEvent(_SetServerUrl(value));
},
);
}
}
class _LegacySignInForm extends StatelessWidget {
const _LegacySignInForm();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
decoration: InputDecoration(
hintText: L10n.global().usernameInputHint,
),
keyboardType: TextInputType.text,
validator: (value) {
if (!context.state.isAltMode) {
return null;
}
if (value!.trim().isEmpty) {
return L10n.global().usernameInputInvalidEmpty;
}
return null;
},
onChanged: (value) {
context.addEvent(_SetUsername(value));
},
),
const SizedBox(height: 8),
_BlocSelector(
selector: (state) => state.shouldObscurePassword,
builder: (context, shouldObscurePassword) => TextFormField(
decoration: InputDecoration(
hintText: L10n.global().passwordInputHint,
suffixIcon: shouldObscurePassword
? IconButton(
icon: const Icon(Icons.visibility_off_outlined),
onPressed: () {
context.addEvent(const _SetObscurePassword(false));
},
)
: IconButton(
icon: const Icon(Icons.visibility_outlined),
onPressed: () {
context.addEvent(const _SetObscurePassword(true));
},
),
),
keyboardType: TextInputType.text,
obscureText: shouldObscurePassword,
validator: (value) {
if (!context.state.isAltMode) {
return null;
}
if (value!.trim().isEmpty) {
return L10n.global().passwordInputInvalidEmpty;
}
return null;
},
onChanged: (value) {
context.addEvent(_SetPassword(value));
},
),
),
],
);
}
}