mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
refactor(mobile): form & form field (#25042)
* refactor: form & form field * chore: remove unused components --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
af2c232c87
commit
da248414af
18 changed files with 817 additions and 274 deletions
|
|
@ -42,6 +42,7 @@ import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
import 'package:immich_mobile/utils/licenses.dart';
|
import 'package:immich_mobile/utils/licenses.dart';
|
||||||
import 'package:immich_mobile/utils/migration.dart';
|
import 'package:immich_mobile/utils/migration.dart';
|
||||||
import 'package:immich_mobile/wm_executor.dart';
|
import 'package:immich_mobile/wm_executor.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:timezone/data/latest.dart';
|
import 'package:timezone/data/latest.dart';
|
||||||
|
|
@ -252,6 +253,13 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||||
themeMode: ref.watch(immichThemeModeProvider),
|
themeMode: ref.watch(immichThemeModeProvider),
|
||||||
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
|
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
|
||||||
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
|
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
|
||||||
|
builder: (context, child) => ImmichTranslationProvider(
|
||||||
|
translations: ImmichTranslations(
|
||||||
|
submit: "submit".t(context: context),
|
||||||
|
password: "password".t(context: context),
|
||||||
|
),
|
||||||
|
child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!),
|
||||||
|
),
|
||||||
routerConfig: router.config(
|
routerConfig: router.config(
|
||||||
deepLinkBuilder: _deepLinkBuilder,
|
deepLinkBuilder: _deepLinkBuilder,
|
||||||
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
|
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,17 @@ List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color)
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ComponentTitle extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const _ComponentTitle(this.title);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(title, style: context.textTheme.titleLarge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class ImmichUIShowcasePage extends StatelessWidget {
|
class ImmichUIShowcasePage extends StatelessWidget {
|
||||||
const ImmichUIShowcasePage({super.key});
|
const ImmichUIShowcasePage({super.key});
|
||||||
|
|
@ -35,13 +46,51 @@ class ImmichUIShowcasePage extends StatelessWidget {
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text("IconButton", style: context.textTheme.titleLarge),
|
const _ComponentTitle("IconButton"),
|
||||||
..._showcaseBuilder(
|
..._showcaseBuilder(
|
||||||
(variant, color) =>
|
(variant, color) =>
|
||||||
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}),
|
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}),
|
||||||
|
),
|
||||||
|
const _ComponentTitle("CloseButton"),
|
||||||
|
..._showcaseBuilder(
|
||||||
|
(variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}),
|
||||||
|
),
|
||||||
|
const _ComponentTitle("TextButton"),
|
||||||
|
|
||||||
|
ImmichTextButton(
|
||||||
|
labelText: "Text Button",
|
||||||
|
onPressed: () {},
|
||||||
|
variant: ImmichVariant.filled,
|
||||||
|
color: ImmichColor.primary,
|
||||||
|
),
|
||||||
|
ImmichTextButton(
|
||||||
|
labelText: "Text Button",
|
||||||
|
onPressed: () {},
|
||||||
|
variant: ImmichVariant.filled,
|
||||||
|
color: ImmichColor.primary,
|
||||||
|
loading: true,
|
||||||
|
),
|
||||||
|
ImmichTextButton(
|
||||||
|
labelText: "Text Button",
|
||||||
|
onPressed: () {},
|
||||||
|
variant: ImmichVariant.ghost,
|
||||||
|
color: ImmichColor.primary,
|
||||||
|
),
|
||||||
|
ImmichTextButton(
|
||||||
|
labelText: "Text Button",
|
||||||
|
onPressed: () {},
|
||||||
|
variant: ImmichVariant.ghost,
|
||||||
|
color: ImmichColor.primary,
|
||||||
|
loading: true,
|
||||||
|
),
|
||||||
|
const _ComponentTitle("Form"),
|
||||||
|
ImmichForm(
|
||||||
|
onSubmit: () {},
|
||||||
|
child: const Column(
|
||||||
|
spacing: 10,
|
||||||
|
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text("CloseButton", style: context.textTheme.titleLarge),
|
|
||||||
..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class DriftCropImagePage extends HookWidget {
|
||||||
icon: Icons.done_rounded,
|
icon: Icons.done_rounded,
|
||||||
color: ImmichColor.primary,
|
color: ImmichColor.primary,
|
||||||
variant: ImmichVariant.ghost,
|
variant: ImmichVariant.ghost,
|
||||||
onTap: () async {
|
onPressed: () async {
|
||||||
final croppedImage = await cropController.croppedImage();
|
final croppedImage = await cropController.croppedImage();
|
||||||
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
|
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
|
||||||
},
|
},
|
||||||
|
|
@ -79,13 +79,13 @@ class DriftCropImagePage extends HookWidget {
|
||||||
icon: Icons.rotate_left,
|
icon: Icons.rotate_left,
|
||||||
variant: ImmichVariant.ghost,
|
variant: ImmichVariant.ghost,
|
||||||
color: ImmichColor.secondary,
|
color: ImmichColor.secondary,
|
||||||
onTap: () => cropController.rotateLeft(),
|
onPressed: () => cropController.rotateLeft(),
|
||||||
),
|
),
|
||||||
ImmichIconButton(
|
ImmichIconButton(
|
||||||
icon: Icons.rotate_right,
|
icon: Icons.rotate_right,
|
||||||
variant: ImmichVariant.ghost,
|
variant: ImmichVariant.ghost,
|
||||||
color: ImmichColor.secondary,
|
color: ImmichColor.secondary,
|
||||||
onTap: () => cropController.rotateRight(),
|
onPressed: () => cropController.rotateRight(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
|
@ -29,12 +30,7 @@ import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
|
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
|
|
||||||
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
|
|
||||||
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
|
|
||||||
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
|
|
||||||
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
@ -45,16 +41,33 @@ class LoginForm extends HookConsumerWidget {
|
||||||
|
|
||||||
final log = Logger('LoginForm');
|
final log = Logger('LoginForm');
|
||||||
|
|
||||||
|
String? _validateUrl(String? url) {
|
||||||
|
if (url == null || url.isEmpty) return null;
|
||||||
|
|
||||||
|
final parsedUrl = Uri.tryParse(url);
|
||||||
|
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
|
||||||
|
return 'login_form_err_invalid_url'.tr();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateEmail(String? email) {
|
||||||
|
if (email == null || email == '') return null;
|
||||||
|
if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
|
||||||
|
if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
|
||||||
|
if (email.contains(' ') || !email.contains('@')) {
|
||||||
|
return 'login_form_err_invalid_email'.tr();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final emailController = useTextEditingController.fromValue(TextEditingValue.empty);
|
final emailController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
|
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty);
|
final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final emailFocusNode = useFocusNode();
|
|
||||||
final passwordFocusNode = useFocusNode();
|
final passwordFocusNode = useFocusNode();
|
||||||
final serverEndpointFocusNode = useFocusNode();
|
|
||||||
final isLoading = useState<bool>(false);
|
|
||||||
final isLoadingServer = useState<bool>(false);
|
|
||||||
final isOauthEnable = useState<bool>(false);
|
final isOauthEnable = useState<bool>(false);
|
||||||
final isPasswordLoginEnable = useState<bool>(false);
|
final isPasswordLoginEnable = useState<bool>(false);
|
||||||
final oAuthButtonLabel = useState<String>('OAuth');
|
final oAuthButtonLabel = useState<String>('OAuth');
|
||||||
|
|
@ -96,7 +109,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isLoadingServer.value = true;
|
|
||||||
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
|
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
|
||||||
|
|
||||||
// Fetch and load server config and features
|
// Fetch and load server config and features
|
||||||
|
|
@ -120,7 +132,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
);
|
);
|
||||||
isOauthEnable.value = false;
|
isOauthEnable.value = false;
|
||||||
isPasswordLoginEnable.value = true;
|
isPasswordLoginEnable.value = true;
|
||||||
isLoadingServer.value = false;
|
|
||||||
} on HandshakeException {
|
} on HandshakeException {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -130,7 +141,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
);
|
);
|
||||||
isOauthEnable.value = false;
|
isOauthEnable.value = false;
|
||||||
isPasswordLoginEnable.value = true;
|
isPasswordLoginEnable.value = true;
|
||||||
isLoadingServer.value = false;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -140,10 +150,7 @@ class LoginForm extends HookConsumerWidget {
|
||||||
);
|
);
|
||||||
isOauthEnable.value = false;
|
isOauthEnable.value = false;
|
||||||
isPasswordLoginEnable.value = true;
|
isPasswordLoginEnable.value = true;
|
||||||
isLoadingServer.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingServer.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
|
|
@ -230,8 +237,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
login() async {
|
login() async {
|
||||||
TextInput.finishAutofillContext();
|
TextInput.finishAutofillContext();
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
// Invalidate all api repository provider instance to take into account new access token
|
// Invalidate all api repository provider instance to take into account new access token
|
||||||
invalidateAllApiRepositoryProviders(ref);
|
invalidateAllApiRepositoryProviders(ref);
|
||||||
|
|
||||||
|
|
@ -261,8 +266,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
toastType: ToastType.error,
|
toastType: ToastType.error,
|
||||||
gravity: ToastGravity.TOP,
|
gravity: ToastGravity.TOP,
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,8 +309,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
codeChallenge,
|
codeChallenge,
|
||||||
);
|
);
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
// Invalidate all api repository provider instance to take into account new access token
|
// Invalidate all api repository provider instance to take into account new access token
|
||||||
invalidateAllApiRepositoryProviders(ref);
|
invalidateAllApiRepositoryProviders(ref);
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
|
|
@ -319,7 +320,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
toastType: ToastType.error,
|
toastType: ToastType.error,
|
||||||
gravity: ToastGravity.TOP,
|
gravity: ToastGravity.TOP,
|
||||||
);
|
);
|
||||||
isLoading.value = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,7 +338,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
|
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
isLoading.value = false;
|
|
||||||
final permission = ref.watch(galleryPermissionNotifier);
|
final permission = ref.watch(galleryPermissionNotifier);
|
||||||
final isBeta = Store.isBetaTimelineEnabled;
|
final isBeta = Store.isBetaTimelineEnabled;
|
||||||
if (!isBeta && (permission.isGranted || permission.isLimited)) {
|
if (!isBeta && (permission.isGranted || permission.isLimited)) {
|
||||||
|
|
@ -364,9 +363,7 @@ class LoginForm extends HookConsumerWidget {
|
||||||
toastType: ToastType.error,
|
toastType: ToastType.error,
|
||||||
gravity: ToastGravity.TOP,
|
gravity: ToastGravity.TOP,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {}
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -374,66 +371,10 @@ class LoginForm extends HookConsumerWidget {
|
||||||
toastType: ToastType.info,
|
toastType: ToastType.info,
|
||||||
gravity: ToastGravity.TOP,
|
gravity: ToastGravity.TOP,
|
||||||
);
|
);
|
||||||
isLoading.value = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildSelectServer() {
|
|
||||||
const buttonRadius = 25.0;
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
ServerEndpointInput(
|
|
||||||
controller: serverEndpointController,
|
|
||||||
focusNode: serverEndpointFocusNode,
|
|
||||||
onSubmit: getServerAuthSettings,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.only(
|
|
||||||
topLeft: Radius.circular(buttonRadius),
|
|
||||||
bottomLeft: Radius.circular(buttonRadius),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: () => context.pushRoute(const SettingsRoute()),
|
|
||||||
icon: const Icon(Icons.settings_rounded),
|
|
||||||
label: const Text(""),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 1),
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.only(
|
|
||||||
topRight: Radius.circular(buttonRadius),
|
|
||||||
bottomRight: Radius.circular(buttonRadius),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: isLoadingServer.value ? null : getServerAuthSettings,
|
|
||||||
icon: const Icon(Icons.arrow_forward_rounded),
|
|
||||||
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
if (isLoadingServer.value) const LoadingIcon(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildVersionCompatWarning() {
|
buildVersionCompatWarning() {
|
||||||
checkVersionMismatch();
|
checkVersionMismatch();
|
||||||
|
|
||||||
|
|
@ -455,66 +396,102 @@ class LoginForm extends HookConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildLogin() {
|
final serverSelectionOrLogin = serverEndpoint.value == null
|
||||||
return AutofillGroup(
|
? Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.only(top: ImmichSpacing.md),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.max,
|
||||||
buildVersionCompatWarning(),
|
children: [
|
||||||
Text(
|
ImmichForm(
|
||||||
sanitizeUrl(serverEndpointController.text),
|
submitText: 'next'.t(context: context),
|
||||||
style: context.textTheme.displaySmall,
|
submitIcon: Icons.arrow_forward_rounded,
|
||||||
textAlign: TextAlign.center,
|
onSubmit: getServerAuthSettings,
|
||||||
|
child: ImmichTextInput(
|
||||||
|
controller: serverEndpointController,
|
||||||
|
label: 'login_form_endpoint_url'.t(context: context),
|
||||||
|
hintText: 'login_form_endpoint_hint'.t(context: context),
|
||||||
|
validator: _validateUrl,
|
||||||
|
keyboardAction: TextInputAction.next,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
autofillHints: const [AutofillHints.url],
|
||||||
|
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ImmichTextButton(
|
||||||
|
labelText: 'settings'.t(context: context),
|
||||||
|
icon: Icons.settings,
|
||||||
|
variant: ImmichVariant.ghost,
|
||||||
|
onPressed: () => context.pushRoute(const SettingsRoute()),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (isPasswordLoginEnable.value) ...[
|
)
|
||||||
const SizedBox(height: 18),
|
: AutofillGroup(
|
||||||
EmailInput(
|
child: Column(
|
||||||
controller: emailController,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
focusNode: emailFocusNode,
|
mainAxisSize: MainAxisSize.max,
|
||||||
onSubmit: passwordFocusNode.requestFocus,
|
children: [
|
||||||
),
|
buildVersionCompatWarning(),
|
||||||
const SizedBox(height: 8),
|
Padding(
|
||||||
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login),
|
padding: const EdgeInsets.only(bottom: ImmichSpacing.md),
|
||||||
],
|
child: Text(
|
||||||
|
sanitizeUrl(serverEndpointController.text),
|
||||||
// Note: This used to have an AnimatedSwitcher, but was removed
|
style: context.textTheme.displaySmall,
|
||||||
// because of https://github.com/flutter/flutter/issues/120874
|
textAlign: TextAlign.center,
|
||||||
isLoading.value
|
),
|
||||||
? const LoadingIcon()
|
),
|
||||||
: Column(
|
if (isPasswordLoginEnable.value)
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
ImmichForm(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
submitText: 'login'.t(context: context),
|
||||||
children: [
|
submitIcon: Icons.login_rounded,
|
||||||
const SizedBox(height: 18),
|
onSubmit: login,
|
||||||
if (isPasswordLoginEnable.value) LoginButton(onPressed: login),
|
child: Column(
|
||||||
if (isOauthEnable.value) ...[
|
spacing: ImmichSpacing.md,
|
||||||
if (isPasswordLoginEnable.value)
|
children: [
|
||||||
Padding(
|
ImmichTextInput(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
controller: emailController,
|
||||||
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
|
label: 'email'.t(context: context),
|
||||||
),
|
hintText: 'login_form_email_hint'.t(context: context),
|
||||||
OAuthLoginButton(
|
validator: _validateEmail,
|
||||||
serverEndpointController: serverEndpointController,
|
keyboardAction: TextInputAction.next,
|
||||||
buttonLabel: oAuthButtonLabel.value,
|
keyboardType: TextInputType.emailAddress,
|
||||||
isLoading: isLoading,
|
autofillHints: const [AutofillHints.email],
|
||||||
onPressed: oAuthLogin,
|
onSubmit: (_, _) => passwordFocusNode.requestFocus(),
|
||||||
|
),
|
||||||
|
ImmichPasswordInput(
|
||||||
|
controller: passwordController,
|
||||||
|
focusNode: passwordFocusNode,
|
||||||
|
label: 'password'.t(context: context),
|
||||||
|
hintText: 'login_form_password_hint'.t(context: context),
|
||||||
|
keyboardAction: TextInputAction.go,
|
||||||
|
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()),
|
if (isOauthEnable.value)
|
||||||
const SizedBox(height: 12),
|
ImmichForm(
|
||||||
TextButton.icon(
|
submitText: oAuthButtonLabel.value,
|
||||||
icon: const Icon(Icons.arrow_back),
|
submitIcon: Icons.pin_outlined,
|
||||||
onPressed: () => serverEndpoint.value = null,
|
onSubmit: oAuthLogin,
|
||||||
label: const Text('back').tr(),
|
child: isPasswordLoginEnable.value
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0),
|
||||||
|
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
if (!isOauthEnable.value && !isPasswordLoginEnable.value)
|
||||||
|
Center(child: const Text('login_disabled').tr()),
|
||||||
|
ImmichTextButton(
|
||||||
|
labelText: 'back'.t(context: context),
|
||||||
|
icon: Icons.arrow_back,
|
||||||
|
variant: ImmichVariant.ghost,
|
||||||
|
onPressed: () => serverEndpoint.value = null,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin();
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
||||||
|
|
||||||
class OAuthLoginButton extends ConsumerWidget {
|
|
||||||
final TextEditingController serverEndpointController;
|
|
||||||
final ValueNotifier<bool> isLoading;
|
|
||||||
final String buttonLabel;
|
|
||||||
final Function() onPressed;
|
|
||||||
|
|
||||||
const OAuthLoginButton({
|
|
||||||
super.key,
|
|
||||||
required this.serverEndpointController,
|
|
||||||
required this.isLoading,
|
|
||||||
required this.buttonLabel,
|
|
||||||
required this.onPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return ElevatedButton.icon(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: context.primaryColor.withAlpha(230),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
),
|
|
||||||
onPressed: onPressed,
|
|
||||||
icon: const Icon(Icons.pin_rounded),
|
|
||||||
label: Text(buttonLabel, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
class PasswordInput extends HookConsumerWidget {
|
|
||||||
final TextEditingController controller;
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
final Function()? onSubmit;
|
|
||||||
|
|
||||||
const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isPasswordVisible = useState<bool>(false);
|
|
||||||
|
|
||||||
return TextFormField(
|
|
||||||
obscureText: !isPasswordVisible.value,
|
|
||||||
controller: controller,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'password'.tr(),
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
hintText: 'login_form_password_hint'.tr(),
|
|
||||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
onPressed: () => isPasswordVisible.value = !isPasswordVisible.value,
|
|
||||||
icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
autofillHints: const [AutofillHints.password],
|
|
||||||
keyboardType: TextInputType.text,
|
|
||||||
onFieldSubmitted: (_) => onSubmit?.call(),
|
|
||||||
focusNode: focusNode,
|
|
||||||
textInputAction: TextInputAction.go,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
|
||||||
|
|
||||||
class ServerEndpointInput extends StatelessWidget {
|
|
||||||
final TextEditingController controller;
|
|
||||||
final FocusNode focusNode;
|
|
||||||
final Function()? onSubmit;
|
|
||||||
|
|
||||||
const ServerEndpointInput({super.key, required this.controller, required this.focusNode, this.onSubmit});
|
|
||||||
|
|
||||||
String? _validateInput(String? url) {
|
|
||||||
if (url == null || url.isEmpty) return null;
|
|
||||||
|
|
||||||
final parsedUrl = Uri.tryParse(sanitizeUrl(url));
|
|
||||||
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
|
|
||||||
return 'login_form_err_invalid_url'.tr();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16.0),
|
|
||||||
child: TextFormField(
|
|
||||||
controller: controller,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'login_form_endpoint_url'.tr(),
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
hintText: 'login_form_endpoint_hint'.tr(),
|
|
||||||
errorMaxLines: 4,
|
|
||||||
),
|
|
||||||
validator: _validateInput,
|
|
||||||
autovalidateMode: AutovalidateMode.always,
|
|
||||||
focusNode: focusNode,
|
|
||||||
autofillHints: const [AutofillHints.url],
|
|
||||||
keyboardType: TextInputType.url,
|
|
||||||
autocorrect: false,
|
|
||||||
onFieldSubmitted: (_) => onSubmit?.call(),
|
|
||||||
textInputAction: TextInputAction.go,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
export 'src/buttons/close_button.dart';
|
export 'src/components/close_button.dart';
|
||||||
export 'src/buttons/icon_button.dart';
|
export 'src/components/form.dart';
|
||||||
|
export 'src/components/icon_button.dart';
|
||||||
|
export 'src/components/password_input.dart';
|
||||||
|
export 'src/components/text_button.dart';
|
||||||
|
export 'src/components/text_input.dart';
|
||||||
|
export 'src/constants.dart';
|
||||||
|
export 'src/theme.dart';
|
||||||
|
export 'src/translation.dart';
|
||||||
export 'src/types.dart';
|
export 'src/types.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_ui/src/buttons/icon_button.dart';
|
|
||||||
import 'package:immich_ui/src/types.dart';
|
import 'package:immich_ui/src/types.dart';
|
||||||
|
|
||||||
|
import 'icon_button.dart';
|
||||||
|
|
||||||
class ImmichCloseButton extends StatelessWidget {
|
class ImmichCloseButton extends StatelessWidget {
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onPressed;
|
||||||
final ImmichVariant variant;
|
final ImmichVariant variant;
|
||||||
final ImmichColor color;
|
final ImmichColor color;
|
||||||
|
|
||||||
const ImmichCloseButton({
|
const ImmichCloseButton({
|
||||||
super.key,
|
super.key,
|
||||||
this.onTap,
|
this.onPressed,
|
||||||
this.color = ImmichColor.primary,
|
this.color = ImmichColor.primary,
|
||||||
this.variant = ImmichVariant.ghost,
|
this.variant = ImmichVariant.ghost,
|
||||||
});
|
});
|
||||||
|
|
@ -20,6 +21,6 @@ class ImmichCloseButton extends StatelessWidget {
|
||||||
icon: Icons.close,
|
icon: Icons.close,
|
||||||
color: color,
|
color: color,
|
||||||
variant: variant,
|
variant: variant,
|
||||||
onTap: onTap ?? () => Navigator.of(context).pop(),
|
onPressed: onPressed ?? () => Navigator.of(context).pop(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
98
mobile/packages/ui/lib/src/components/form.dart
Normal file
98
mobile/packages/ui/lib/src/components/form.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
import 'package:immich_ui/src/internal.dart';
|
||||||
|
|
||||||
|
class ImmichForm extends StatefulWidget {
|
||||||
|
final String? submitText;
|
||||||
|
final IconData? submitIcon;
|
||||||
|
final FutureOr<void> Function()? onSubmit;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ImmichForm({
|
||||||
|
super.key,
|
||||||
|
this.submitText,
|
||||||
|
this.submitIcon,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImmichForm> createState() => ImmichFormState();
|
||||||
|
|
||||||
|
static ImmichFormState of(BuildContext context) {
|
||||||
|
final scope = context.dependOnInheritedWidgetOfExactType<_ImmichFormScope>();
|
||||||
|
if (scope == null) {
|
||||||
|
throw FlutterError(
|
||||||
|
'ImmichForm.of() called with a context that does not contain an ImmichForm.\n'
|
||||||
|
'No ImmichForm ancestor could be found starting from the context that was passed to '
|
||||||
|
'ImmichForm.of(). This usually happens when the context provided is '
|
||||||
|
'from a widget above the ImmichForm.\n'
|
||||||
|
'The context used was:\n'
|
||||||
|
'$context',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return scope._formState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImmichFormState extends State<ImmichForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
FutureOr<void> submit() async {
|
||||||
|
final isValid = _formKey.currentState?.validate() ?? false;
|
||||||
|
if (!isValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.onSubmit?.call();
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final submitText = widget.submitText ?? context.translations.submit;
|
||||||
|
return _ImmichFormScope(
|
||||||
|
formState: this,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
spacing: ImmichSpacing.md,
|
||||||
|
children: [
|
||||||
|
widget.child,
|
||||||
|
ImmichTextButton(
|
||||||
|
labelText: submitText,
|
||||||
|
icon: widget.submitIcon,
|
||||||
|
variant: ImmichVariant.filled,
|
||||||
|
loading: _isLoading,
|
||||||
|
onPressed: submit,
|
||||||
|
disabled: widget.onSubmit == null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImmichFormScope extends InheritedWidget {
|
||||||
|
const _ImmichFormScope({required super.child, required ImmichFormState formState}) : _formState = formState;
|
||||||
|
|
||||||
|
final ImmichFormState _formState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(_ImmichFormScope oldWidget) => oldWidget._formState != _formState;
|
||||||
|
}
|
||||||
|
|
@ -3,42 +3,48 @@ import 'package:immich_ui/src/types.dart';
|
||||||
|
|
||||||
class ImmichIconButton extends StatelessWidget {
|
class ImmichIconButton extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onPressed;
|
||||||
final ImmichVariant variant;
|
final ImmichVariant variant;
|
||||||
final ImmichColor color;
|
final ImmichColor color;
|
||||||
|
final bool disabled;
|
||||||
|
|
||||||
const ImmichIconButton({
|
const ImmichIconButton({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.onTap,
|
required this.onPressed,
|
||||||
this.color = ImmichColor.primary,
|
this.color = ImmichColor.primary,
|
||||||
this.variant = ImmichVariant.filled,
|
this.variant = ImmichVariant.filled,
|
||||||
|
this.disabled = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
final background = switch (variant) {
|
final background = switch (variant) {
|
||||||
ImmichVariant.filled => switch (color) {
|
ImmichVariant.filled => switch (color) {
|
||||||
ImmichColor.primary => Theme.of(context).colorScheme.primary,
|
ImmichColor.primary => colorScheme.primary,
|
||||||
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
|
ImmichColor.secondary => colorScheme.secondary,
|
||||||
},
|
},
|
||||||
ImmichVariant.ghost => Colors.transparent,
|
ImmichVariant.ghost => Colors.transparent,
|
||||||
};
|
};
|
||||||
|
|
||||||
final foreground = switch (variant) {
|
final foreground = switch (variant) {
|
||||||
ImmichVariant.filled => switch (color) {
|
ImmichVariant.filled => switch (color) {
|
||||||
ImmichColor.primary => Theme.of(context).colorScheme.onPrimary,
|
ImmichColor.primary => colorScheme.onPrimary,
|
||||||
ImmichColor.secondary => Theme.of(context).colorScheme.onSecondary,
|
ImmichColor.secondary => colorScheme.onSecondary,
|
||||||
},
|
},
|
||||||
ImmichVariant.ghost => switch (color) {
|
ImmichVariant.ghost => switch (color) {
|
||||||
ImmichColor.primary => Theme.of(context).colorScheme.primary,
|
ImmichColor.primary => colorScheme.primary,
|
||||||
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
|
ImmichColor.secondary => colorScheme.secondary,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
final effectiveOnPressed = disabled ? null : onPressed;
|
||||||
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
onPressed: onTap,
|
onPressed: effectiveOnPressed,
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: background,
|
backgroundColor: background,
|
||||||
foregroundColor: foreground,
|
foregroundColor: foreground,
|
||||||
58
mobile/packages/ui/lib/src/components/password_input.dart
Normal file
58
mobile/packages/ui/lib/src/components/password_input.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_ui/src/components/text_input.dart';
|
||||||
|
import 'package:immich_ui/src/internal.dart';
|
||||||
|
|
||||||
|
class ImmichPasswordInput extends StatefulWidget {
|
||||||
|
final String? label;
|
||||||
|
final String? hintText;
|
||||||
|
final TextEditingController? controller;
|
||||||
|
final FocusNode? focusNode;
|
||||||
|
final String? Function(String?)? validator;
|
||||||
|
final void Function(BuildContext, String)? onSubmit;
|
||||||
|
final TextInputAction? keyboardAction;
|
||||||
|
|
||||||
|
const ImmichPasswordInput({
|
||||||
|
super.key,
|
||||||
|
this.controller,
|
||||||
|
this.focusNode,
|
||||||
|
this.label,
|
||||||
|
this.hintText,
|
||||||
|
this.validator,
|
||||||
|
this.onSubmit,
|
||||||
|
this.keyboardAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State createState() => _ImmichPasswordInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
|
||||||
|
bool _visible = false;
|
||||||
|
|
||||||
|
void _toggleVisibility() {
|
||||||
|
setState(() {
|
||||||
|
_visible = !_visible;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ImmichTextInput(
|
||||||
|
key: widget.key,
|
||||||
|
label: widget.label ?? context.translations.password,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
controller: widget.controller,
|
||||||
|
focusNode: widget.focusNode,
|
||||||
|
validator: widget.validator,
|
||||||
|
onSubmit: widget.onSubmit,
|
||||||
|
keyboardAction: widget.keyboardAction,
|
||||||
|
obscureText: !_visible,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: _toggleVisibility,
|
||||||
|
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
||||||
|
),
|
||||||
|
autofillHints: [AutofillHints.password],
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
mobile/packages/ui/lib/src/components/text_button.dart
Normal file
87
mobile/packages/ui/lib/src/components/text_button.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_ui/src/constants.dart';
|
||||||
|
import 'package:immich_ui/src/types.dart';
|
||||||
|
|
||||||
|
class ImmichTextButton extends StatelessWidget {
|
||||||
|
final String labelText;
|
||||||
|
final IconData? icon;
|
||||||
|
final FutureOr<void> Function() onPressed;
|
||||||
|
final ImmichVariant variant;
|
||||||
|
final ImmichColor color;
|
||||||
|
final bool expanded;
|
||||||
|
final bool loading;
|
||||||
|
final bool disabled;
|
||||||
|
|
||||||
|
const ImmichTextButton({
|
||||||
|
super.key,
|
||||||
|
required this.labelText,
|
||||||
|
this.icon,
|
||||||
|
required this.onPressed,
|
||||||
|
this.variant = ImmichVariant.filled,
|
||||||
|
this.color = ImmichColor.primary,
|
||||||
|
this.expanded = true,
|
||||||
|
this.loading = false,
|
||||||
|
this.disabled = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget _buildButton(ImmichVariant variant) {
|
||||||
|
final Widget? effectiveIcon = loading
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: ImmichIconSize.md,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
|
||||||
|
)
|
||||||
|
: icon != null
|
||||||
|
? Icon(icon, fontWeight: FontWeight.w600)
|
||||||
|
: null;
|
||||||
|
final hasIcon = effectiveIcon != null;
|
||||||
|
|
||||||
|
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
|
||||||
|
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
|
||||||
|
|
||||||
|
final effectiveOnPressed = disabled || loading ? null : onPressed;
|
||||||
|
|
||||||
|
switch (variant) {
|
||||||
|
case ImmichVariant.filled:
|
||||||
|
if (hasIcon) {
|
||||||
|
return ElevatedButton.icon(
|
||||||
|
style: style,
|
||||||
|
onPressed: effectiveOnPressed,
|
||||||
|
icon: effectiveIcon,
|
||||||
|
label: label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ElevatedButton(
|
||||||
|
style: style,
|
||||||
|
onPressed: effectiveOnPressed,
|
||||||
|
child: label,
|
||||||
|
);
|
||||||
|
case ImmichVariant.ghost:
|
||||||
|
if (hasIcon) {
|
||||||
|
return TextButton.icon(
|
||||||
|
style: style,
|
||||||
|
onPressed: effectiveOnPressed,
|
||||||
|
icon: effectiveIcon,
|
||||||
|
label: label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextButton(
|
||||||
|
style: style,
|
||||||
|
onPressed: effectiveOnPressed,
|
||||||
|
child: label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final button = _buildButton(variant);
|
||||||
|
if (expanded) {
|
||||||
|
return SizedBox(width: double.infinity, child: button);
|
||||||
|
}
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
mobile/packages/ui/lib/src/components/text_input.dart
Normal file
88
mobile/packages/ui/lib/src/components/text_input.dart
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ImmichTextInput extends StatefulWidget {
|
||||||
|
final String label;
|
||||||
|
final String? hintText;
|
||||||
|
final TextEditingController? controller;
|
||||||
|
final FocusNode? focusNode;
|
||||||
|
final String? Function(String?)? validator;
|
||||||
|
final void Function(BuildContext, String)? onSubmit;
|
||||||
|
final TextInputType keyboardType;
|
||||||
|
final TextInputAction? keyboardAction;
|
||||||
|
final List<String>? autofillHints;
|
||||||
|
final Widget? suffixIcon;
|
||||||
|
final bool obscureText;
|
||||||
|
|
||||||
|
const ImmichTextInput({
|
||||||
|
super.key,
|
||||||
|
this.controller,
|
||||||
|
this.focusNode,
|
||||||
|
required this.label,
|
||||||
|
this.hintText,
|
||||||
|
this.validator,
|
||||||
|
this.onSubmit,
|
||||||
|
this.keyboardType = TextInputType.text,
|
||||||
|
this.keyboardAction,
|
||||||
|
this.autofillHints,
|
||||||
|
this.suffixIcon,
|
||||||
|
this.obscureText = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State createState() => _ImmichTextInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImmichTextInputState extends State<ImmichTextInput> {
|
||||||
|
late final FocusNode _focusNode;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusNode = widget.focusNode ?? FocusNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (widget.focusNode == null) {
|
||||||
|
_focusNode.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateInput(String? value) {
|
||||||
|
setState(() {
|
||||||
|
_error = widget.validator?.call(value);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasError => _error != null && _error!.isNotEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final themeData = Theme.of(context);
|
||||||
|
|
||||||
|
return TextFormField(
|
||||||
|
controller: widget.controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.hintText,
|
||||||
|
labelText: widget.label,
|
||||||
|
labelStyle: themeData.inputDecorationTheme.labelStyle?.copyWith(
|
||||||
|
color: _hasError ? themeData.colorScheme.error : null,
|
||||||
|
),
|
||||||
|
errorText: _error,
|
||||||
|
suffixIcon: widget.suffixIcon,
|
||||||
|
),
|
||||||
|
obscureText: widget.obscureText,
|
||||||
|
validator: _validateInput,
|
||||||
|
keyboardType: widget.keyboardType,
|
||||||
|
textInputAction: widget.keyboardAction,
|
||||||
|
autofillHints: widget.autofillHints,
|
||||||
|
onTap: () => setState(() => _error = null),
|
||||||
|
onTapOutside: (_) => _focusNode.unfocus(),
|
||||||
|
onFieldSubmitted: (value) => widget.onSubmit?.call(context, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
199
mobile/packages/ui/lib/src/constants.dart
Normal file
199
mobile/packages/ui/lib/src/constants.dart
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
/// Spacing constants for gaps between widgets
|
||||||
|
abstract class ImmichSpacing {
|
||||||
|
const ImmichSpacing._();
|
||||||
|
|
||||||
|
/// Extra small spacing: 4.0
|
||||||
|
static const double xs = 4.0;
|
||||||
|
|
||||||
|
/// Small spacing: 8.0
|
||||||
|
static const double sm = 8.0;
|
||||||
|
|
||||||
|
/// Medium spacing (default): 12.0
|
||||||
|
static const double md = 12.0;
|
||||||
|
|
||||||
|
/// Large spacing: 16.0
|
||||||
|
static const double lg = 16.0;
|
||||||
|
|
||||||
|
/// Extra large spacing: 24.0
|
||||||
|
static const double xl = 24.0;
|
||||||
|
|
||||||
|
/// Extra extra large spacing: 32.0
|
||||||
|
static const double xxl = 32.0;
|
||||||
|
|
||||||
|
/// Extra extra extra large spacing: 48.0
|
||||||
|
static const double xxxl = 48.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border radius constants for consistent rounded corners
|
||||||
|
abstract class ImmichRadius {
|
||||||
|
const ImmichRadius._();
|
||||||
|
|
||||||
|
/// No radius: 0.0
|
||||||
|
static const double none = 0.0;
|
||||||
|
|
||||||
|
/// Extra small radius: 4.0
|
||||||
|
static const double xs = 4.0;
|
||||||
|
|
||||||
|
/// Small radius: 8.0
|
||||||
|
static const double sm = 8.0;
|
||||||
|
|
||||||
|
/// Medium radius (default): 12.0
|
||||||
|
static const double md = 12.0;
|
||||||
|
|
||||||
|
/// Large radius: 16.0
|
||||||
|
static const double lg = 16.0;
|
||||||
|
|
||||||
|
/// Extra large radius: 20.0
|
||||||
|
static const double xl = 20.0;
|
||||||
|
|
||||||
|
/// Extra extra large radius: 24.0
|
||||||
|
static const double xxl = 24.0;
|
||||||
|
|
||||||
|
/// Full circular radius: infinity
|
||||||
|
static const double full = double.infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icon size constants for consistent icon sizing
|
||||||
|
abstract class ImmichIconSize {
|
||||||
|
const ImmichIconSize._();
|
||||||
|
|
||||||
|
/// Extra small icon: 16.0
|
||||||
|
static const double xs = 16.0;
|
||||||
|
|
||||||
|
/// Small icon: 20.0
|
||||||
|
static const double sm = 20.0;
|
||||||
|
|
||||||
|
/// Medium icon (default): 24.0
|
||||||
|
static const double md = 24.0;
|
||||||
|
|
||||||
|
/// Large icon: 32.0
|
||||||
|
static const double lg = 32.0;
|
||||||
|
|
||||||
|
/// Extra large icon: 40.0
|
||||||
|
static const double xl = 40.0;
|
||||||
|
|
||||||
|
/// Extra extra large icon: 48.0
|
||||||
|
static const double xxl = 48.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animation duration constants for consistent timing
|
||||||
|
abstract class ImmichDuration {
|
||||||
|
const ImmichDuration._();
|
||||||
|
|
||||||
|
/// Extra fast: 100ms
|
||||||
|
static const Duration extraFast = Duration(milliseconds: 100);
|
||||||
|
|
||||||
|
/// Fast: 150ms
|
||||||
|
static const Duration fast = Duration(milliseconds: 150);
|
||||||
|
|
||||||
|
/// Normal: 200ms
|
||||||
|
static const Duration normal = Duration(milliseconds: 200);
|
||||||
|
|
||||||
|
/// Moderate: 300ms
|
||||||
|
static const Duration moderate = Duration(milliseconds: 300);
|
||||||
|
|
||||||
|
/// Slow: 500ms
|
||||||
|
static const Duration slow = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
/// Extra slow: 700ms
|
||||||
|
static const Duration extraSlow = Duration(milliseconds: 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elevation constants for consistent shadows and depth
|
||||||
|
abstract class ImmichElevation {
|
||||||
|
const ImmichElevation._();
|
||||||
|
|
||||||
|
/// No elevation: 0.0
|
||||||
|
static const double none = 0.0;
|
||||||
|
|
||||||
|
/// Extra small elevation: 1.0
|
||||||
|
static const double xs = 1.0;
|
||||||
|
|
||||||
|
/// Small elevation: 2.0
|
||||||
|
static const double sm = 2.0;
|
||||||
|
|
||||||
|
/// Medium elevation: 4.0
|
||||||
|
static const double md = 4.0;
|
||||||
|
|
||||||
|
/// Large elevation: 8.0
|
||||||
|
static const double lg = 8.0;
|
||||||
|
|
||||||
|
/// Extra large elevation: 12.0
|
||||||
|
static const double xl = 12.0;
|
||||||
|
|
||||||
|
/// Extra extra large elevation: 16.0
|
||||||
|
static const double xxl = 16.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border width constants (similar to Tailwind's border-* scale)
|
||||||
|
abstract class ImmichBorderWidth {
|
||||||
|
const ImmichBorderWidth._();
|
||||||
|
|
||||||
|
/// No border: 0.0
|
||||||
|
static const double none = 0.0;
|
||||||
|
|
||||||
|
/// Hairline border: 0.5
|
||||||
|
static const double hairline = 0.5;
|
||||||
|
|
||||||
|
/// Default border: 1.0 (border)
|
||||||
|
static const double base = 1.0;
|
||||||
|
|
||||||
|
/// Medium border: 2.0 (border-2)
|
||||||
|
static const double md = 2.0;
|
||||||
|
|
||||||
|
/// Large border: 3.0 (border-4)
|
||||||
|
static const double lg = 3.0;
|
||||||
|
|
||||||
|
/// Extra large border: 4.0
|
||||||
|
static const double xl = 4.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text size constants with semantic HTML-like naming
|
||||||
|
/// These follow a type scale for harmonious text hierarchy
|
||||||
|
abstract class ImmichTextSize {
|
||||||
|
const ImmichTextSize._();
|
||||||
|
|
||||||
|
/// Caption text: 10.0
|
||||||
|
/// Use for: Tiny labels, legal text, metadata, timestamps
|
||||||
|
static const double caption = 10.0;
|
||||||
|
|
||||||
|
/// Label text: 12.0
|
||||||
|
/// Use for: Form labels, secondary text, helper text
|
||||||
|
static const double label = 12.0;
|
||||||
|
|
||||||
|
/// Body text: 14.0 (default)
|
||||||
|
/// Use for: Main body text, paragraphs, default UI text
|
||||||
|
static const double body = 14.0;
|
||||||
|
|
||||||
|
/// Body emphasized: 16.0
|
||||||
|
/// Use for: Emphasized body text, button labels, tabs
|
||||||
|
static const double bodyLarge = 16.0;
|
||||||
|
|
||||||
|
/// Heading 6: 18.0 (smallest heading)
|
||||||
|
/// Use for: Subtitles, card titles, section headers
|
||||||
|
static const double h6 = 18.0;
|
||||||
|
|
||||||
|
/// Heading 5: 20.0
|
||||||
|
/// Use for: Small headings, prominent labels
|
||||||
|
static const double h5 = 20.0;
|
||||||
|
|
||||||
|
/// Heading 4: 24.0
|
||||||
|
/// Use for: Page titles, dialog titles
|
||||||
|
static const double h4 = 24.0;
|
||||||
|
|
||||||
|
/// Heading 3: 30.0
|
||||||
|
/// Use for: Section headings, large headings
|
||||||
|
static const double h3 = 30.0;
|
||||||
|
|
||||||
|
/// Heading 2: 36.0
|
||||||
|
/// Use for: Major section headings
|
||||||
|
static const double h2 = 36.0;
|
||||||
|
|
||||||
|
/// Heading 1: 48.0 (largest heading)
|
||||||
|
/// Use for: Page hero headings, main titles
|
||||||
|
static const double h1 = 48.0;
|
||||||
|
|
||||||
|
/// Display text: 60.0
|
||||||
|
/// Use for: Hero numbers, splash screens, extra large display
|
||||||
|
static const double display = 60.0;
|
||||||
|
}
|
||||||
6
mobile/packages/ui/lib/src/internal.dart
Normal file
6
mobile/packages/ui/lib/src/internal.dart
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_ui/src/translation.dart';
|
||||||
|
|
||||||
|
extension TranslationHelper on BuildContext {
|
||||||
|
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
|
||||||
|
}
|
||||||
42
mobile/packages/ui/lib/src/theme.dart
Normal file
42
mobile/packages/ui/lib/src/theme.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_ui/src/constants.dart';
|
||||||
|
|
||||||
|
class ImmichThemeProvider extends StatelessWidget {
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ImmichThemeProvider({super.key, required this.colorScheme, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
brightness: colorScheme.brightness,
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: colorScheme.primary),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: colorScheme.primary),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: colorScheme.error),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: colorScheme.error),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.w600),
|
||||||
|
hintStyle: const TextStyle(fontSize: ImmichTextSize.body),
|
||||||
|
errorStyle: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
mobile/packages/ui/lib/src/translation.dart
Normal file
31
mobile/packages/ui/lib/src/translation.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ImmichTranslations {
|
||||||
|
late String submit;
|
||||||
|
late String password;
|
||||||
|
|
||||||
|
ImmichTranslations({String? submit, String? password}) {
|
||||||
|
this.submit = submit ?? 'Submit';
|
||||||
|
this.password = password ?? 'Password';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImmichTranslationProvider extends InheritedWidget {
|
||||||
|
final ImmichTranslations? translations;
|
||||||
|
|
||||||
|
const ImmichTranslationProvider({
|
||||||
|
super.key,
|
||||||
|
this.translations,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
static ImmichTranslations of(BuildContext context) {
|
||||||
|
final provider = context.dependOnInheritedWidgetOfExactType<ImmichTranslationProvider>();
|
||||||
|
return provider?.translations ?? ImmichTranslations();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(covariant ImmichTranslationProvider oldWidget) {
|
||||||
|
return oldWidget.translations != translations;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue