add adaptive_scaffold

This commit is contained in:
shenlong-tanwen
2024-05-24 09:42:02 +05:30
parent fb6253d2d1
commit 1631df70e9
295 changed files with 2540 additions and 44480 deletions
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
@immutable
class LoginPageState {
final bool isServerValidated;
final bool isValidationInProgress;
const LoginPageState({
required this.isServerValidated,
required this.isValidationInProgress,
});
factory LoginPageState.reset() {
return const LoginPageState(
isServerValidated: false,
isValidationInProgress: false,
);
}
LoginPageState copyWith({
bool? isServerValidated,
bool? isValidationInProgress,
}) {
return LoginPageState(
isServerValidated: isServerValidated ?? this.isServerValidated,
isValidationInProgress:
isValidationInProgress ?? this.isValidationInProgress,
);
}
@override
String toString() =>
'LoginPageState(isServerValidated: $isServerValidated, isValidationInProgress: $isValidationInProgress)';
@override
bool operator ==(covariant LoginPageState other) {
if (identical(this, other)) return true;
return other.isServerValidated == isServerValidated &&
other.isValidationInProgress == isValidationInProgress;
}
@override
int get hashCode =>
isServerValidated.hashCode ^ isValidationInProgress.hashCode;
}
@@ -0,0 +1,187 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/presentation/components/common/gap.widget.dart';
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_scaffold_body.widget.dart';
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
import 'package:immich_mobile/presentation/modules/login/widgets/login_form.widget.dart';
import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/utils/constants/size_constants.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@RoutePage()
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
final TextEditingController _serverUrlController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(seconds: 60),
vsync: this,
)..repeat();
}
@override
void dispose() {
_animationController.dispose();
_serverUrlController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _populateDemoCredentials() {
_serverUrlController.text = 'https://demo.immich.app';
_emailController.text = 'demo@immich.app';
_passwordController.text = 'demo';
}
@override
Widget build(BuildContext context) {
final PreferredSizeWidget? appBar;
late final Widget primaryBody;
late final Widget secondaryBody;
Widget rotatingLogo = GestureDetector(
onDoubleTap: _populateDemoCredentials,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RotationTransition(
turns: _animationController,
child: const ImLogo(width: 100),
),
const SizedGap.lh(),
const ImLogoText(),
],
),
),
);
final Widget form = FractionallySizedBox(
widthFactor: 0.8,
child: LoginForm(
serverUrlController: _serverUrlController,
emailController: _emailController,
passwordController: _passwordController,
),
);
final Widget bottom = Padding(
padding: const EdgeInsets.only(bottom: SizeConstants.s),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FutureBuilder(
future: PackageInfo.fromPlatform(),
builder: (_, snap) => DefaultTextStyle.merge(
style: TextStyle(color: context.theme.colorScheme.outline),
child: Text(snap.data?.version ?? ''),
),
),
TextButton(
onPressed: () => context.navigateRoot(const LogsRoute()),
child: const Text('Logs'),
),
],
),
);
final serverUrl = BlocSelector<LoginPageCubit, LoginPageState, bool>(
selector: (state) => state.isServerValidated,
builder: (_, isValidated) => isValidated
? Padding(
padding: const EdgeInsets.only(bottom: SizeConstants.m),
child: DefaultTextStyle.merge(
style: TextStyle(
color: context.theme.primaryColor,
fontWeight: FontWeight.w500,
),
child: InkWell(
onTap: () => launchUrl(Uri.parse(_serverUrlController.text)),
child: Text(
_serverUrlController.text,
textAlign: TextAlign.center,
),
),
),
)
: const SizedBox.shrink(),
);
const PreferredSizeWidget topBar = _MobileAppBar();
if (context.isTablet) {
appBar = null;
primaryBody = Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [rotatingLogo, const SizedGap.mh(), serverUrl],
),
);
secondaryBody = Column(
children: [topBar, Expanded(child: Center(child: form)), bottom],
);
} else {
appBar = topBar;
primaryBody = Center(
child: Column(children: [
Expanded(child: rotatingLogo),
serverUrl,
Expanded(flex: 2, child: form),
bottom,
]),
);
}
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: appBar,
body: SafeArea(
child: ImAdaptiveScaffoldBody(
primaryBody: (_) => primaryBody,
secondaryBody: (_) => secondaryBody,
),
),
);
}
}
class _MobileAppBar extends StatelessWidget implements PreferredSizeWidget {
const _MobileAppBar();
@override
Widget build(BuildContext context) {
return AppBar(
automaticallyImplyLeading: false,
scrolledUnderElevation: 0.0,
actions: [
IconButton(
onPressed: () => context.navigateRoot(const SettingsRoute()),
icon: const Icon(Symbols.settings),
),
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/login.service.dart';
import 'package:immich_mobile/domain/store_manager.dart';
import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/mixins/log_context.mixin.dart';
import 'package:immich_mobile/utils/snackbar_manager.dart';
class LoginPageCubit extends Cubit<LoginPageState> with LogContext {
LoginPageCubit() : super(LoginPageState.reset());
String _appendSchema(String url) {
// Add schema if none is set
url =
url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url";
// Remove trailing slash(es)
url = url.trimRight().replaceFirst(RegExp(r"/+$"), "");
return url;
}
String? validateServerUrl(String? url) {
if (url == null || url.isEmpty) {
return t.login.error.empty_server_url;
}
url = _appendSchema(url);
final uri = Uri.tryParse(url);
if (uri == null ||
!uri.isAbsolute ||
uri.host.isEmpty ||
!uri.scheme.startsWith("http")) {
return t.login.error.invalid_server_url;
}
return null;
}
Future<void> validateServer(String url) async {
url = _appendSchema(url);
final LoginService loginService = di();
try {
// parse instead of tryParse since the method expects a valid well formed URI
final uri = Uri.parse(url);
emit(state.copyWith(isValidationInProgress: true));
// Check if the endpoint is available
if (!await loginService.isEndpointAvailable(uri)) {
SnackbarManager.showError(t.login.error.server_not_reachable);
return;
}
// Check for /.well-known/immich
url = await loginService.resolveEndpoint(uri);
di<StoreManager>().put(StoreKey.serverEndpoint, url);
ServiceLocator.registerPostValidationServices(url);
// Fetch server features
await di<ServerFeatureConfigCubit>().getFeatures();
emit(state.copyWith(isServerValidated: true));
} finally {
emit(state.copyWith(isValidationInProgress: false));
}
}
Future<void> passwordLogin({
required String email,
required String password,
}) async {
emit(state.copyWith(isValidationInProgress: true));
final url = di<StoreManager>().get(StoreKey.serverEndpoint);
}
Future<void> oAuthLogin() async {
emit(state.copyWith(isValidationInProgress: true));
final url = di<StoreManager>().get(StoreKey.serverEndpoint);
}
void resetServerValidation() {
emit(LoginPageState.reset());
}
}
@@ -0,0 +1,187 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/domain/models/server-info/server_feature_config.model.dart';
import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/components/common/gap.widget.dart';
import 'package:immich_mobile/presentation/components/common/loading_indaticator.widget.dart';
import 'package:immich_mobile/presentation/components/input/filled_button.widget.dart';
import 'package:immich_mobile/presentation/components/input/password_form_field.widget.dart';
import 'package:immich_mobile/presentation/components/input/text_button.widget.dart';
import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart';
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:material_symbols_icons/symbols.dart';
class LoginForm extends StatelessWidget {
final TextEditingController serverUrlController;
final TextEditingController emailController;
final TextEditingController passwordController;
const LoginForm({
super.key,
required this.serverUrlController,
required this.emailController,
required this.passwordController,
});
@override
Widget build(BuildContext context) {
return BlocSelector<LoginPageCubit, LoginPageState, bool>(
selector: (model) => model.isServerValidated,
builder: (_, isServerValidated) => AnimatedSwitcher(
duration: Durations.medium1,
child: SingleChildScrollView(
child: isServerValidated
? _CredentialsPage(
emailController: emailController,
passwordController: passwordController,
)
: _ServerPage(controller: serverUrlController),
),
layoutBuilder: (current, previous) =>
current ?? (previous.lastOrNull ?? const SizedBox.shrink()),
),
);
}
}
class _ServerPage extends StatelessWidget {
final TextEditingController controller;
final GlobalKey<FormState> _formKey = GlobalKey();
_ServerPage({required this.controller});
Future<void> _validateForm(BuildContext context) async {
if (_formKey.currentState?.validate() == true) {
await context.read<LoginPageCubit>().validateServer(controller.text);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: BlocSelector<LoginPageCubit, LoginPageState, bool>(
selector: (model) => model.isValidationInProgress,
builder: (_, isValidationInProgress) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ImTextFormField(
controller: controller,
label: context.t.login.label.endpoint,
validator: context.read<LoginPageCubit>().validateServerUrl,
autoFillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
isDisabled: isValidationInProgress,
),
const SizedGap.mh(),
ImFilledButton(
label: context.t.login.label.next_button,
icon: Symbols.arrow_forward_rounded,
onPressed: () => unawaited(_validateForm(context)),
isDisabled: isValidationInProgress,
),
const SizedGap.mh(),
if (isValidationInProgress) const ImLoadingIndicator(),
],
),
),
);
}
}
class _CredentialsPage extends StatefulWidget {
final TextEditingController emailController;
final TextEditingController passwordController;
const _CredentialsPage({
required this.emailController,
required this.passwordController,
});
@override
State<_CredentialsPage> createState() => _CredentialsPageState();
}
class _CredentialsPageState extends State<_CredentialsPage> {
final passwordFocusNode = FocusNode();
@override
void dispose() {
passwordFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<LoginPageCubit, LoginPageState, bool>(
selector: (model) => model.isValidationInProgress,
builder: (_, isValidationInProgress) => isValidationInProgress
? const ImLoadingIndicator()
: BlocBuilder<ServerFeatureConfigCubit, ServerFeatureConfig>(
bloc: di(),
builder: (_, state) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (state.features.hasPasswordLogin) ...[
ImTextFormField(
label: context.t.login.label.email,
isDisabled: isValidationInProgress,
textInputAction: TextInputAction.next,
onSubmitted: (_) => passwordFocusNode.requestFocus(),
),
const SizedGap.mh(),
ImPasswordFormField(
label: context.t.login.label.password,
focusNode: passwordFocusNode,
isDisabled: isValidationInProgress,
textInputAction: TextInputAction.go,
),
const SizedGap.mh(),
ImFilledButton(
label: context.t.login.label.login_button,
icon: Symbols.login_rounded,
onPressed: () =>
context.read<LoginPageCubit>().passwordLogin(
email: widget.emailController.text,
password: widget.passwordController.text,
),
),
// Divider when both password and oAuth login is enabled
if (state.features.hasOAuthLogin) const Divider(),
],
if (state.features.hasOAuthLogin)
ImFilledButton(
label: state.config.oauthButtonText ??
context.t.login.label.oauth_button,
icon: Symbols.pin_rounded,
onPressed: () => unawaited(
context.read<LoginPageCubit>().oAuthLogin(),
),
),
if (!state.features.hasPasswordLogin &&
!state.features.hasOAuthLogin)
ImFilledButton(
label: context.t.login.label.login_disabled,
isDisabled: true,
),
const SizedGap.sh(),
ImTextButton(
label: context.t.login.label.back_button,
icon: Symbols.arrow_back_rounded,
onPressed:
context.read<LoginPageCubit>().resetServerValidation,
),
],
),
),
);
}
}