add adaptive_scaffold
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/utils/constants/size_constants.dart';
|
||||
|
||||
@immutable
|
||||
class SizedGap extends SizedBox {
|
||||
const SizedGap({super.key, super.height, super.width});
|
||||
|
||||
// Widgets to be used in Column
|
||||
const SizedGap.sh({super.key}) : super(height: SizeConstants.s);
|
||||
const SizedGap.mh({super.key}) : super(height: SizeConstants.m);
|
||||
const SizedGap.lh({super.key}) : super(height: SizeConstants.l);
|
||||
const SizedGap.xlh({super.key}) : super(height: SizeConstants.xl);
|
||||
|
||||
// Widgets to be used in Row
|
||||
const SizedGap.sw({super.key}) : super(width: SizeConstants.s);
|
||||
const SizedGap.mw({super.key}) : super(width: SizeConstants.m);
|
||||
const SizedGap.lw({super.key}) : super(width: SizeConstants.l);
|
||||
const SizedGap.xlw({super.key}) : super(width: SizeConstants.xl);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImLoadingIndicator extends StatelessWidget {
|
||||
const ImLoadingIndicator({super.key, this.dimension, this.strokeWidth});
|
||||
|
||||
/// The size of the indicator with a default of 24
|
||||
final double? dimension;
|
||||
|
||||
/// The width of the indicator with a default of 2
|
||||
final double? strokeWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: dimension ?? 24,
|
||||
height: dimension ?? 24,
|
||||
child: FittedBox(
|
||||
child: CircularProgressIndicator(strokeWidth: strokeWidth ?? 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/utils/constants/assets.gen.dart';
|
||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
|
||||
class ImLogo extends StatelessWidget {
|
||||
const ImLogo({
|
||||
this.width,
|
||||
this.filterQuality = FilterQuality.high,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The width of the image.
|
||||
final double? width;
|
||||
|
||||
/// The rendering quality
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Image(
|
||||
width: width,
|
||||
filterQuality: filterQuality,
|
||||
semanticLabel: 'Immich Logo',
|
||||
image: Assets.images.immichLogo.provider(),
|
||||
isAntiAlias: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImLogoText extends StatelessWidget {
|
||||
const ImLogoText({
|
||||
super.key,
|
||||
this.fontSize = 48,
|
||||
this.filterQuality = FilterQuality.high,
|
||||
});
|
||||
|
||||
final double fontSize;
|
||||
|
||||
/// The rendering quality
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Image(
|
||||
semanticLabel: 'Immich Logo Text',
|
||||
image: (context.isDarkTheme
|
||||
? Assets.images.immichTextDark.provider
|
||||
: Assets.images.immichTextLight.provider)(),
|
||||
width: fontSize * 4,
|
||||
filterQuality: FilterQuality.high,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImFilledButton extends StatelessWidget {
|
||||
const ImFilledButton({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.isDisabled = false,
|
||||
required this.label,
|
||||
}) : _tonal = false;
|
||||
|
||||
const ImFilledButton.tonal({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.isDisabled = false,
|
||||
required this.label,
|
||||
}) : _tonal = true;
|
||||
|
||||
/// Internal flag to switch between filled and tonal variant
|
||||
final bool _tonal;
|
||||
|
||||
/// Should disable the button
|
||||
final bool isDisabled;
|
||||
|
||||
/// Icon to display if [withIcon] is true
|
||||
final IconData? icon;
|
||||
|
||||
/// Action to perform on Button press
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// Label to be displayed in the button
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_tonal) {
|
||||
if (icon != null) {
|
||||
return FilledButton.tonalIcon(
|
||||
onPressed: isDisabled ? null : onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
return FilledButton.tonal(
|
||||
onPressed: isDisabled ? null : onPressed,
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
if (icon != null) {
|
||||
return FilledButton.icon(
|
||||
onPressed: isDisabled ? null : onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
return FilledButton(
|
||||
onPressed: isDisabled ? null : onPressed,
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class ImPasswordFormField extends StatefulWidget {
|
||||
const ImPasswordFormField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.onChanged,
|
||||
this.focusNode,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.textInputAction,
|
||||
this.isDisabled = false,
|
||||
});
|
||||
|
||||
/// The [TextEditingController] passed to the underlying [TextFormField]
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// Optional callback to receive changes
|
||||
final void Function(String?)? onChanged;
|
||||
|
||||
/// The [FocusNode] passed to the underlying [TextFormField]
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// Translation Key used as label
|
||||
final String? label;
|
||||
|
||||
/// Translation key used as hint
|
||||
final String? hint;
|
||||
|
||||
/// Type of the following action - go, next, enter, etc.
|
||||
final TextInputAction? textInputAction;
|
||||
|
||||
/// Flag to disable the [TextFormField]
|
||||
final bool isDisabled;
|
||||
|
||||
@override
|
||||
State createState() => _ImPasswordFormFieldState();
|
||||
}
|
||||
|
||||
class _ImPasswordFormFieldState extends State<ImPasswordFormField> {
|
||||
final showPassword = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
showPassword.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: showPassword,
|
||||
builder: (_, showPass, child) => ImTextFormField(
|
||||
controller: widget.controller,
|
||||
onChanged: widget.onChanged,
|
||||
shouldObscure: !showPass,
|
||||
hint: widget.hint,
|
||||
label: widget.label,
|
||||
focusNode: widget.focusNode,
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () => showPassword.value = !showPassword.value,
|
||||
icon: Icon(
|
||||
showPassword.value
|
||||
? Symbols.visibility_off_rounded
|
||||
: Symbols.visibility_rounded,
|
||||
),
|
||||
),
|
||||
autoFillHints: const [AutofillHints.password],
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: widget.textInputAction,
|
||||
isDisabled: widget.isDisabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/app_setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/app_setting.service.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
|
||||
class ImSwitchListTile<T> extends StatefulWidget {
|
||||
const ImSwitchListTile(
|
||||
this.setting, {
|
||||
super.key,
|
||||
this.fromAppSetting,
|
||||
this.toAppSetting,
|
||||
}) : assert(T == bool || (fromAppSetting != null && toAppSetting != null),
|
||||
"Setting is not a boolean and a from / to App setting is not provided");
|
||||
|
||||
final AppSetting<T> setting;
|
||||
|
||||
/// Converts the type T to a boolean to use in a switch
|
||||
final bool Function(T value)? fromAppSetting;
|
||||
|
||||
/// Converts the boolean back to the type T to be stored in the app setting. Return null to not update the DB but to
|
||||
/// retain the previous value
|
||||
final T? Function(bool state)? toAppSetting;
|
||||
|
||||
@override
|
||||
State createState() => _ImSwitchListTileState<T>();
|
||||
}
|
||||
|
||||
class _ImSwitchListTileState<T> extends State<ImSwitchListTile<T>> {
|
||||
// Actual switch list state
|
||||
late bool isEnabled;
|
||||
final AppSettingService _appSettingService = di();
|
||||
|
||||
Future<void> set(bool enabled) async {
|
||||
if (isEnabled == enabled) return;
|
||||
|
||||
final value = T != bool ? widget.toAppSetting!(enabled) : enabled as T;
|
||||
if (value != null &&
|
||||
await _appSettingService.setSetting(widget.setting, value) &&
|
||||
context.mounted) {
|
||||
setState(() {
|
||||
isEnabled = enabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final value = _appSettingService.getSetting(widget.setting);
|
||||
isEnabled = T != bool ? widget.fromAppSetting!(value) : value as bool;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile(
|
||||
value: isEnabled,
|
||||
onChanged: (value) => set(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImTextButton extends StatelessWidget {
|
||||
const ImTextButton({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.isDisabled = false,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
/// Icon to display if [withIcon] is true
|
||||
final IconData? icon;
|
||||
|
||||
/// Flag to disable the button
|
||||
final bool isDisabled;
|
||||
|
||||
/// Action to perform on Button press
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// Label to be displayed in the button
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (icon != null) {
|
||||
return TextButton.icon(
|
||||
onPressed: isDisabled ? null : onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
return TextButton(onPressed: onPressed, child: Text(label));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImTextFormField extends StatelessWidget {
|
||||
const ImTextFormField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.focusNode,
|
||||
this.onChanged,
|
||||
this.validator,
|
||||
this.shouldObscure = false,
|
||||
this.suffixIcon,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.autoFillHints,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.isDisabled = false,
|
||||
this.onSubmitted,
|
||||
}) : assert(
|
||||
onSubmitted == null ||
|
||||
textInputAction == TextInputAction.next ||
|
||||
textInputAction == TextInputAction.previous,
|
||||
"onSubmitted provided when textInputAction is not next or pervious",
|
||||
);
|
||||
|
||||
/// The [TextEditingController] passed to the underlying [TextFormField]
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// The [FocusNode] passed to the underlying [TextFormField]
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// Optional callback to validate input
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
/// Optional callback to receive changes
|
||||
final void Function(String?)? onChanged;
|
||||
|
||||
/// Optional flag to obscure texts
|
||||
final bool shouldObscure;
|
||||
|
||||
/// Icon Widget to display in the suffix
|
||||
final Widget? suffixIcon;
|
||||
|
||||
/// Translation Key used as label
|
||||
final String? label;
|
||||
|
||||
/// Translation key used as hint
|
||||
final String? hint;
|
||||
|
||||
/// Hints used by the auto-fill service
|
||||
final List<String>? autoFillHints;
|
||||
|
||||
/// Type of keyboard - Numberic / Alphanum
|
||||
final TextInputType? keyboardType;
|
||||
|
||||
/// Type of the following action - go, next, enter, etc.
|
||||
final TextInputAction? textInputAction;
|
||||
|
||||
/// Flag to disable the [TextFormField]
|
||||
final bool isDisabled;
|
||||
|
||||
/// Called on [TextInputAction.next] or [TextInputAction.previous]
|
||||
final void Function(String)? onSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
focusNode: focusNode,
|
||||
obscureText: shouldObscure,
|
||||
validator: validator,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
suffixIcon: suffixIcon,
|
||||
),
|
||||
autofillHints: autoFillHints,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
readOnly: isDisabled,
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onFieldSubmitted: onSubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImAdaptiveRoutePrimaryAppBar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
const ImAdaptiveRoutePrimaryAppBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
leading: BackButton(onPressed: () => context.router.root.maybePop()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
|
||||
class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
const ImAdaptiveRouteSecondaryAppBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
leading: context.isTablet
|
||||
? CloseButton(onPressed: () => context.maybePop())
|
||||
: BackButton(onPressed: () => context.maybePop()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_scaffold_body.widget.dart';
|
||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
|
||||
class ImAdaptiveRouteWrapper extends StatelessWidget {
|
||||
const ImAdaptiveRouteWrapper({
|
||||
super.key,
|
||||
required this.primaryRoute,
|
||||
required this.primaryBody,
|
||||
this.bodyRatio,
|
||||
});
|
||||
|
||||
/// Builder to build the primary body
|
||||
final Widget Function(BuildContext?) primaryBody;
|
||||
|
||||
/// Primary route name to not render it twice in landscape
|
||||
final String primaryRoute;
|
||||
|
||||
/// Ratio of primaryBody:secondaryBody
|
||||
final double? bodyRatio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AutoRouter(builder: (ctx, child) {
|
||||
if (ctx.isTablet) {
|
||||
return ImAdaptiveScaffoldBody(
|
||||
primaryBody: primaryBody,
|
||||
secondaryBody:
|
||||
ctx.topRoute.name != primaryRoute ? (_) => child : null,
|
||||
bodyRatio: bodyRatio,
|
||||
);
|
||||
}
|
||||
return ImAdaptiveScaffoldBody(primaryBody: (_) => child);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
|
||||
|
||||
class ImAdaptiveScaffoldBody extends StatelessWidget {
|
||||
const ImAdaptiveScaffoldBody({
|
||||
super.key,
|
||||
required this.primaryBody,
|
||||
this.secondaryBody,
|
||||
this.bodyRatio,
|
||||
});
|
||||
|
||||
/// Builder to build the primary body
|
||||
final Widget Function(BuildContext?) primaryBody;
|
||||
|
||||
/// Builder to build the secondary body
|
||||
final Widget Function(BuildContext?)? secondaryBody;
|
||||
|
||||
/// Ratio of primaryBody:secondaryBody
|
||||
final double? bodyRatio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AdaptiveLayout(
|
||||
internalAnimations: false,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
bodyRatio: bodyRatio,
|
||||
body: SlotLayout(
|
||||
config: {
|
||||
Breakpoints.standard: SlotLayout.from(
|
||||
key: const Key('ImAdaptiveScaffold Body Standard'),
|
||||
builder: primaryBody,
|
||||
),
|
||||
},
|
||||
),
|
||||
secondaryBody: SlotLayout(
|
||||
config: {
|
||||
/// No secondary body in mobile layouts
|
||||
Breakpoints.small: SlotLayoutConfig.empty(),
|
||||
Breakpoints.mediumAndUp: SlotLayout.from(
|
||||
key: const Key('ImAdaptiveScaffold Secondary Body Medium'),
|
||||
builder: secondaryBody,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:immich_mobile/domain/models/server-info/server_feature_config.model.dart';
|
||||
import 'package:immich_mobile/domain/services/server_info.service.dart';
|
||||
|
||||
class ServerFeatureConfigCubit extends Cubit<ServerFeatureConfig> {
|
||||
final ServerInfoService _serverInfoService;
|
||||
|
||||
ServerFeatureConfigCubit(this._serverInfoService)
|
||||
: super(const ServerFeatureConfig.reset());
|
||||
|
||||
Future<void> getFeatures() async =>
|
||||
await Future.wait([_getFeatures(), _getConfig()]);
|
||||
|
||||
Future<void> _getFeatures() async {
|
||||
final features = await _serverInfoService.getServerFeatures();
|
||||
if (features != null) {
|
||||
emit(state.copyWith(features: features));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _getConfig() async {
|
||||
final config = await _serverInfoService.getServerConfig();
|
||||
if (config != null) {
|
||||
emit(state.copyWith(config: config));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/router/router.dart';
|
||||
|
||||
@RoutePage()
|
||||
class HomePage extends StatelessWidget {
|
||||
@@ -7,6 +8,11 @@ class HomePage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container();
|
||||
return Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => context.router.navigate(const SettingsRoute()),
|
||||
child: const Text('Settings'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LogsPage extends StatelessWidget {
|
||||
const LogsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(body: Center(child: Text("Logs Page")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/router/router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
enum SettingSection {
|
||||
general(
|
||||
icon: Symbols.interests_rounded,
|
||||
labelKey: 'settings.sections.general',
|
||||
destination: GeneralSettingsRoute(),
|
||||
),
|
||||
advance(
|
||||
icon: Symbols.build_rounded,
|
||||
labelKey: 'settings.sections.advance',
|
||||
destination: AdvanceSettingsRoute(),
|
||||
),
|
||||
about(
|
||||
icon: Symbols.help_rounded,
|
||||
labelKey: 'settings.sections.about',
|
||||
destination: AboutSettingsRoute(),
|
||||
);
|
||||
|
||||
final PageRouteInfo destination;
|
||||
final String labelKey;
|
||||
final IconData icon;
|
||||
|
||||
const SettingSection({
|
||||
required this.labelKey,
|
||||
required this.icon,
|
||||
required this.destination,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_secondary_appbar.widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AboutSettingsPage extends StatelessWidget {
|
||||
const AboutSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
appBar: ImAdaptiveRouteSecondaryAppBar(),
|
||||
body: Center(child: Text('About Settings')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_secondary_appbar.widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AdvanceSettingsPage extends StatelessWidget {
|
||||
const AdvanceSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
appBar: ImAdaptiveRouteSecondaryAppBar(),
|
||||
body: Center(child: Text('Advanced Settings')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_secondary_appbar.widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class GeneralSettingsPage extends StatelessWidget {
|
||||
const GeneralSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
appBar: ImAdaptiveRouteSecondaryAppBar(),
|
||||
body: Center(child: Text('General Settings')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,25 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/i18n/strings.g.dart';
|
||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_primary_appbar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_wrapper.widget.dart';
|
||||
import 'package:immich_mobile/presentation/modules/settings/models/settings_section.model.dart';
|
||||
import 'package:immich_mobile/presentation/router/router.dart';
|
||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
|
||||
@RoutePage()
|
||||
class SettingsWrapperPage extends StatelessWidget {
|
||||
const SettingsWrapperPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImAdaptiveRouteWrapper(
|
||||
primaryBody: (_) => const SettingsPage(),
|
||||
primaryRoute: SettingsRoute.name,
|
||||
bodyRatio: 0.3,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class SettingsPage extends StatelessWidget {
|
||||
@@ -7,6 +27,21 @@ class SettingsPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container();
|
||||
return Scaffold(
|
||||
appBar: const ImAdaptiveRoutePrimaryAppBar(),
|
||||
body: ListView.builder(
|
||||
itemCount: SettingSection.values.length,
|
||||
itemBuilder: (_, index) {
|
||||
final section = SettingSection.values.elementAt(index);
|
||||
return ListTile(
|
||||
title: Text(context.t[section.labelKey]),
|
||||
onTap: () {
|
||||
context.navigateRoot(section.destination);
|
||||
},
|
||||
leading: Icon(section.icon),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
abstract class AppColors {
|
||||
const AppColors();
|
||||
|
||||
/// Blue color
|
||||
static const ColorScheme blueLight = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: Color(0xff1145a4),
|
||||
onPrimary: Color(0xffffffff),
|
||||
primaryContainer: Color(0xffdae2ff),
|
||||
onPrimaryContainer: Color(0xff001848),
|
||||
secondary: Color(0xff4b73d3),
|
||||
onSecondary: Color(0xfffefbff),
|
||||
secondaryContainer: Color(0xffeef0ff),
|
||||
onSecondaryContainer: Color(0xff001848),
|
||||
tertiary: Color(0xff814b81),
|
||||
onTertiary: Color(0xfffffbff),
|
||||
tertiaryContainer: Color(0xffffd6fa),
|
||||
onTertiaryContainer: Color(0xff340439),
|
||||
error: Color(0xffba1a1a),
|
||||
onError: Color(0xfffffbff),
|
||||
errorContainer: Color(0xffffdad6),
|
||||
onErrorContainer: Color(0xff410002),
|
||||
surface: Color(0xfffefbff),
|
||||
onSurface: Color(0xff1a1b21),
|
||||
onSurfaceVariant: Color(0xff444651),
|
||||
surfaceContainerHighest: Color(0xffe0e2ef),
|
||||
outline: Color(0xff747782),
|
||||
outlineVariant: Color(0xffc4c6d3),
|
||||
shadow: Color(0xff000000),
|
||||
scrim: Color(0xff000000),
|
||||
inverseSurface: Color(0xff2f3036),
|
||||
onInverseSurface: Color(0xfff1f0f7),
|
||||
inversePrimary: Color(0xffb2c5ff),
|
||||
surfaceTint: Color(0xff06409f),
|
||||
);
|
||||
|
||||
static const ColorScheme blueDark = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: Color(0xffa9c7ff),
|
||||
onPrimary: Color(0xff001b3d),
|
||||
primaryContainer: Color(0xff00468c),
|
||||
onPrimaryContainer: Color(0xffd6e3ff),
|
||||
secondary: Color(0xffd6e3ff),
|
||||
onSecondary: Color(0xff001b3d),
|
||||
secondaryContainer: Color(0xff003063),
|
||||
onSecondaryContainer: Color(0xffd6e3ff),
|
||||
tertiary: Color(0xffeab4f6),
|
||||
onTertiary: Color(0xff310540),
|
||||
tertiaryContainer: Color(0xff61356e),
|
||||
onTertiaryContainer: Color(0xfffad7ff),
|
||||
error: Color(0xffffb4ab),
|
||||
onError: Color(0xff410002),
|
||||
errorContainer: Color(0xff93000a),
|
||||
onErrorContainer: Color(0xffffb4ab),
|
||||
surface: Color(0xff1a1e22),
|
||||
onSurface: Color(0xffe2e2e9),
|
||||
onSurfaceVariant: Color(0xffc2c6d2),
|
||||
surfaceContainerHighest: Color(0xff424852),
|
||||
outline: Color(0xff8c919c),
|
||||
outlineVariant: Color(0xff424751),
|
||||
shadow: Color(0xff000000),
|
||||
scrim: Color(0xff000000),
|
||||
inverseSurface: Color(0xffe1e1e9),
|
||||
onInverseSurface: Color(0xff2e3036),
|
||||
inversePrimary: Color(0xff005db7),
|
||||
surfaceTint: Color(0xffa9c7ff),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/modules/theme/models/app_colors.model.dart';
|
||||
import 'package:immich_mobile/utils/extensions/material_state.extension.dart';
|
||||
|
||||
enum AppTheme {
|
||||
blue(AppColors.blueLight, AppColors.blueDark),
|
||||
// Fallback color for dynamic theme for non-supported platforms
|
||||
dynamic(AppColors.blueLight, AppColors.blueDark);
|
||||
|
||||
final ColorScheme lightSchema;
|
||||
final ColorScheme darkSchema;
|
||||
|
||||
const AppTheme(this.lightSchema, this.darkSchema);
|
||||
|
||||
static ThemeData generateThemeData(ColorScheme color) {
|
||||
return ThemeData(
|
||||
colorScheme: color,
|
||||
primaryColor: color.primary,
|
||||
iconTheme: const IconThemeData(weight: 500, opticalSize: 24),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: color.surface,
|
||||
indicatorColor: color.primary,
|
||||
iconTheme: WidgetStateProperty.resolveWith(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.isSelected) {
|
||||
return IconThemeData(color: color.onPrimary);
|
||||
}
|
||||
return IconThemeData(color: color.onSurface.withAlpha(175));
|
||||
},
|
||||
),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: color.surface,
|
||||
elevation: 3,
|
||||
indicatorColor: color.primary,
|
||||
selectedIconTheme:
|
||||
IconThemeData(weight: 500, opticalSize: 24, color: color.onPrimary),
|
||||
unselectedIconTheme: IconThemeData(
|
||||
weight: 500,
|
||||
opticalSize: 24,
|
||||
color: color.onSurface.withAlpha(175),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
sliderTheme: SliderThemeData(
|
||||
valueIndicatorColor:
|
||||
Color.alphaBlend(color.primary.withAlpha(80), color.onSurface)
|
||||
.withAlpha(240),
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
elevation: 4,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(4.0)),
|
||||
),
|
||||
insetPadding: const EdgeInsets.fromLTRB(20.0, 5.0, 20.0, 25.0),
|
||||
backgroundColor:
|
||||
Color.alphaBlend(color.primary.withAlpha(80), color.onSurface)
|
||||
.withAlpha(240),
|
||||
actionTextColor: color.inversePrimary,
|
||||
contentTextStyle: TextStyle(color: color.onInverseSurface),
|
||||
closeIconColor: color.onInverseSurface,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:immich_mobile/domain/models/app_setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/app_setting.service.dart';
|
||||
import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart';
|
||||
|
||||
class AppThemeCubit extends Cubit<AppTheme> {
|
||||
final AppSettingService _appSettings;
|
||||
StreamSubscription? _appSettingSubscription;
|
||||
|
||||
AppThemeCubit(this._appSettings) : super(AppTheme.blue) {
|
||||
_appSettingSubscription = _appSettings
|
||||
.watchSetting(AppSetting.appTheme)
|
||||
.listen((theme) => emit(theme));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_appSettingSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -1,6 +1,6 @@
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/theme/utils/colors.dart';
|
||||
import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart';
|
||||
|
||||
class AppThemeBuilder extends StatelessWidget {
|
||||
const AppThemeBuilder({
|
||||
@@ -14,26 +14,30 @@ class AppThemeBuilder extends StatelessWidget {
|
||||
|
||||
/// Builds the child widget of this widget, providing a light and dark [ThemeData] based on the
|
||||
/// [theme] passed.
|
||||
final Widget Function(ThemeData lightTheme, ThemeData darkTheme) builder;
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
ThemeData lightTheme,
|
||||
ThemeData darkTheme,
|
||||
) builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Static colors
|
||||
if (theme != AppTheme.dynamic) {
|
||||
final lightTheme = AppColors.getThemeForColorScheme(theme.lightSchema);
|
||||
final darkTheme = AppColors.getThemeForColorScheme(theme.darkSchema);
|
||||
final lightTheme = AppTheme.generateThemeData(theme.lightSchema);
|
||||
final darkTheme = AppTheme.generateThemeData(theme.darkSchema);
|
||||
|
||||
return builder(lightTheme, darkTheme);
|
||||
return builder(context, lightTheme, darkTheme);
|
||||
}
|
||||
|
||||
// Dynamic color builder
|
||||
return DynamicColorBuilder(builder: (lightDynamic, darkDynamic) {
|
||||
final lightTheme =
|
||||
AppColors.getThemeForColorScheme(lightDynamic ?? theme.lightSchema);
|
||||
AppTheme.generateThemeData(lightDynamic ?? theme.lightSchema);
|
||||
final darkTheme =
|
||||
AppColors.getThemeForColorScheme(darkDynamic ?? theme.darkSchema);
|
||||
AppTheme.generateThemeData(darkDynamic ?? theme.darkSchema);
|
||||
|
||||
return builder(lightTheme, darkTheme);
|
||||
return builder(context, lightTheme, darkTheme);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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/image/immich_logo.widget.dart';
|
||||
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
|
||||
import 'package:immich_mobile/presentation/router/router.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/mixins/log_context.mixin.dart';
|
||||
|
||||
@RoutePage()
|
||||
class SplashScreenWrapperPage extends AutoRouter implements AutoRouteWrapper {
|
||||
const SplashScreenWrapperPage({super.key});
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return BlocProvider(create: (_) => LoginPageCubit(), child: this);
|
||||
}
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class SplashScreenPage extends StatefulWidget {
|
||||
const SplashScreenPage({super.key});
|
||||
|
||||
@override
|
||||
State createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreenPage>
|
||||
with SingleTickerProviderStateMixin, LogContext {
|
||||
late final AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(seconds: 30),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: FutureBuilder(
|
||||
future: di.allReady(),
|
||||
builder: (_, snap) {
|
||||
if (snap.hasData) {
|
||||
context.replaceRoute(const LoginRoute());
|
||||
} else if (snap.hasError) {
|
||||
log.severe(
|
||||
"Error while initializing the app",
|
||||
snap.error,
|
||||
snap.stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: RotationTransition(
|
||||
turns: _animationController,
|
||||
child: const ImLogo(width: 100),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,52 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:immich_mobile/presentation/modules/home/pages/home.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/library/pages/library.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/login/pages/login.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/logs/pages/log.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/search/pages/search.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/settings/pages/about_settings.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/settings/pages/advance_settings.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/settings/pages/general_settings.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/settings/pages/settings.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/sharing/pages/sharing.page.dart';
|
||||
import 'package:immich_mobile/presentation/router/pages/splash_screen.page.dart';
|
||||
import 'package:immich_mobile/presentation/router/pages/tab_controller.page.dart';
|
||||
|
||||
part 'router.gr.dart';
|
||||
|
||||
@AutoRouterConfig(replaceInRouteName: 'Page,Route')
|
||||
class AppRouter extends _$AppRouter {
|
||||
class AppRouter extends _$AppRouter implements AutoRouteGuard {
|
||||
AppRouter();
|
||||
|
||||
@override
|
||||
List<AutoRoute> get routes => [
|
||||
AutoRoute(page: TabControllerRoute.page, initial: true, children: [
|
||||
AutoRoute(
|
||||
page: SplashScreenWrapperRoute.page,
|
||||
initial: true,
|
||||
children: [
|
||||
AutoRoute(page: SplashScreenRoute.page, initial: true),
|
||||
AutoRoute(page: LoginRoute.page),
|
||||
],
|
||||
),
|
||||
AutoRoute(page: LogsRoute.page),
|
||||
AutoRoute(page: TabControllerRoute.page, children: [
|
||||
AutoRoute(page: HomeRoute.page),
|
||||
AutoRoute(page: SearchRoute.page),
|
||||
AutoRoute(page: SharingRoute.page),
|
||||
AutoRoute(page: LibraryRoute.page),
|
||||
]),
|
||||
AutoRoute(page: SettingsRoute.page),
|
||||
AutoRoute(page: SettingsWrapperRoute.page, children: [
|
||||
AutoRoute(page: SettingsRoute.page),
|
||||
AutoRoute(page: GeneralSettingsRoute.page),
|
||||
AutoRoute(page: AboutSettingsRoute.page),
|
||||
AutoRoute(page: AdvanceSettingsRoute.page),
|
||||
]),
|
||||
];
|
||||
|
||||
// Global guards
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
// Prevent duplicates
|
||||
resolver.next(resolver.route.name != router.current.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/domain/models/app_setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/app_setting.service.dart';
|
||||
import 'package:immich_mobile/presentation/theme/utils/colors.dart';
|
||||
|
||||
class AppThemeState extends ValueNotifier<AppTheme> {
|
||||
final AppSettingsService _appSettings;
|
||||
StreamSubscription? _appSettingSubscription;
|
||||
|
||||
AppThemeState({required AppSettingsService appSettings})
|
||||
: _appSettings = appSettings,
|
||||
super(AppTheme.blue);
|
||||
|
||||
void init() {
|
||||
_appSettingSubscription =
|
||||
_appSettings.watchSetting(AppSettings.appTheme).listen((themeIndex) {
|
||||
final theme =
|
||||
AppTheme.values.elementAtOrNull(themeIndex) ?? AppTheme.blue;
|
||||
value = theme;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appSettingSubscription?.cancel();
|
||||
return super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum AppTheme {
|
||||
blue(AppColors._blueLight, AppColors._blueDark),
|
||||
// Fallback color for dynamic theme for non-supported platforms
|
||||
dynamic(AppColors._blueLight, AppColors._blueDark);
|
||||
|
||||
final ColorScheme lightSchema;
|
||||
final ColorScheme darkSchema;
|
||||
|
||||
const AppTheme(this.lightSchema, this.darkSchema);
|
||||
}
|
||||
|
||||
class AppColors {
|
||||
const AppColors();
|
||||
|
||||
/// Blue color
|
||||
static const ColorScheme _blueLight = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: Color(0xff1565c0),
|
||||
onPrimary: Color(0xffffffff),
|
||||
primaryContainer: Color(0xffd6e3ff),
|
||||
onPrimaryContainer: Color(0xff001b3d),
|
||||
secondary: Color(0xff3277d2),
|
||||
onSecondary: Color(0xfffdfbff),
|
||||
secondaryContainer: Color(0xffecf0ff),
|
||||
onSecondaryContainer: Color(0xff001b3d),
|
||||
tertiary: Color(0xff7b4d88),
|
||||
onTertiary: Color(0xfffffbff),
|
||||
tertiaryContainer: Color(0xfffad7ff),
|
||||
onTertiaryContainer: Color(0xff310540),
|
||||
error: Color(0xffba1a1a),
|
||||
onError: Color(0xfffffbff),
|
||||
errorContainer: Color(0xffffdad6),
|
||||
onErrorContainer: Color(0xff410002),
|
||||
background: Color(0xfffcfafe),
|
||||
onBackground: Color(0xff191c20),
|
||||
surface: Color(0xfffdfbff),
|
||||
onSurface: Color(0xff191c20),
|
||||
surfaceVariant: Color(0xffdfe2ef),
|
||||
onSurfaceVariant: Color(0xff424751),
|
||||
outline: Color(0xff737782),
|
||||
outlineVariant: Color(0xffc2c6d2),
|
||||
shadow: Color(0xff000000),
|
||||
scrim: Color(0xff000000),
|
||||
inverseSurface: Color(0xff2e3036),
|
||||
onInverseSurface: Color(0xfff0f0f7),
|
||||
inversePrimary: Color(0xffa9c7ff),
|
||||
surfaceTint: Color(0xff00468c),
|
||||
);
|
||||
|
||||
static const ColorScheme _blueDark = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: Color(0xffa9c7ff),
|
||||
onPrimary: Color(0xff001b3d),
|
||||
primaryContainer: Color(0xff00468c),
|
||||
onPrimaryContainer: Color(0xffd6e3ff),
|
||||
secondary: Color(0xffd6e3ff),
|
||||
onSecondary: Color(0xff001b3d),
|
||||
secondaryContainer: Color(0xff003063),
|
||||
onSecondaryContainer: Color(0xffd6e3ff),
|
||||
tertiary: Color(0xffeab4f6),
|
||||
onTertiary: Color(0xff310540),
|
||||
tertiaryContainer: Color(0xff61356e),
|
||||
onTertiaryContainer: Color(0xfffad7ff),
|
||||
error: Color(0xffffb4ab),
|
||||
onError: Color(0xff410002),
|
||||
errorContainer: Color(0xff93000a),
|
||||
onErrorContainer: Color(0xffffb4ab),
|
||||
background: Color(0xff1a1d21),
|
||||
onBackground: Color(0xffe2e2e9),
|
||||
surface: Color(0xff1a1e22),
|
||||
onSurface: Color(0xffe2e2e9),
|
||||
surfaceVariant: Color(0xff424852),
|
||||
onSurfaceVariant: Color(0xffc2c6d2),
|
||||
outline: Color(0xff8c919c),
|
||||
outlineVariant: Color(0xff424751),
|
||||
shadow: Color(0xff000000),
|
||||
scrim: Color(0xff000000),
|
||||
inverseSurface: Color(0xffe1e1e9),
|
||||
onInverseSurface: Color(0xff2e3036),
|
||||
inversePrimary: Color(0xff005db7),
|
||||
surfaceTint: Color(0xffa9c7ff),
|
||||
);
|
||||
|
||||
static ThemeData getThemeForColorScheme(ColorScheme color) {
|
||||
return ThemeData(
|
||||
primaryColor: color.primary,
|
||||
iconTheme: const IconThemeData(weight: 400),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user