From 8450c8cc4f208c4993c501502dc1d7eb5ec1bea6 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sun, 27 Oct 2024 23:43:58 +0530 Subject: [PATCH] feat: appbar --- mobile-v2/analysis_options.yaml | 1 + mobile-v2/assets/i18n/strings.i18n.json | 25 ++- .../interfaces/api/server_api.interface.dart | 8 + .../server-info/server_disk_info.model.dart | 56 ++++++ .../server_feature_config.model.dart | 37 ---- .../models/server-info/server_info.model.dart | 56 ++++++ .../server-info/server_version.model.dart | 38 ++++ .../api/server_api.repository.dart | 39 ++++ .../lib/domain/services/login.service.dart | 5 +- mobile-v2/lib/immich_app.dart | 39 +++- ...pp_bar.widget.dart => app_bar.widget.dart} | 19 +- .../appbar/app_bar_dialog.widget.dart | 189 ++++++++++++++++++ .../appbar/app_bar_dialog_actions.widget.dart | 73 +++++++ .../appbar/app_bar_dialog_server.widget.dart | 123 ++++++++++++ .../appbar/app_bar_dialog_storage.widget.dart | 63 ++++++ .../appbar/app_bar_dialog_version.widget.dart | 68 +++++++ .../components/common/gap.widget.dart | 10 + .../components/common/page_empty.widget.dart | 36 ++++ .../components/common/user_avatar.widget.dart | 2 +- ..._grid.state.dart => asset_grid.state.dart} | 0 ...rid.widget.dart => asset_grid.widget.dart} | 37 +++- ...get.dart => asset_grid_header.widget.dart} | 5 +- ...get.dart => asset_render_grid.widget.dart} | 16 +- .../image/immich_thumbnail.widget.dart | 3 +- .../modules/home/pages/home.page.dart | 6 +- .../modules/login/pages/login.page.dart | 14 +- .../login/widgets/login_form.widget.dart | 4 +- .../modules/logs/pages/logs.page.dart | 18 +- .../settings/pages/about_settings.page.dart | 5 +- .../router/pages/tab_controller.page.dart | 14 +- mobile-v2/lib/presentation/router/router.dart | 2 +- .../presentation/states/app_info.state.dart | 112 +++++++++++ .../states/server_feature_config.state.dart | 28 --- .../states/server_info.state.dart | 45 +++++ .../lib/presentation/theme/app_colors.dart | 68 +------ .../lib/presentation/theme/app_theme.dart | 48 +++-- mobile-v2/lib/service_locator.dart | 8 +- .../lib/utils/constants/size_constants.dart | 16 +- .../utils/extensions/number.extension.dart | 24 +++ .../utils/extensions/string.extension.dart | 1 + 40 files changed, 1150 insertions(+), 211 deletions(-) create mode 100644 mobile-v2/lib/domain/models/server-info/server_disk_info.model.dart delete mode 100644 mobile-v2/lib/domain/models/server-info/server_feature_config.model.dart create mode 100644 mobile-v2/lib/domain/models/server-info/server_info.model.dart create mode 100644 mobile-v2/lib/domain/models/server-info/server_version.model.dart rename mobile-v2/lib/presentation/components/appbar/{immich_app_bar.widget.dart => app_bar.widget.dart} (70%) create mode 100644 mobile-v2/lib/presentation/components/appbar/app_bar_dialog.widget.dart create mode 100644 mobile-v2/lib/presentation/components/appbar/app_bar_dialog_actions.widget.dart create mode 100644 mobile-v2/lib/presentation/components/appbar/app_bar_dialog_server.widget.dart create mode 100644 mobile-v2/lib/presentation/components/appbar/app_bar_dialog_storage.widget.dart create mode 100644 mobile-v2/lib/presentation/components/appbar/app_bar_dialog_version.widget.dart create mode 100644 mobile-v2/lib/presentation/components/common/page_empty.widget.dart rename mobile-v2/lib/presentation/components/grid/{immich_asset_grid.state.dart => asset_grid.state.dart} (100%) rename mobile-v2/lib/presentation/components/grid/{immich_asset_grid.widget.dart => asset_grid.widget.dart} (80%) rename mobile-v2/lib/presentation/components/grid/{immich_asset_grid_header.widget.dart => asset_grid_header.widget.dart} (87%) rename mobile-v2/lib/presentation/components/grid/{immich_asset_render_grid.widget.dart => asset_render_grid.widget.dart} (66%) create mode 100644 mobile-v2/lib/presentation/states/app_info.state.dart delete mode 100644 mobile-v2/lib/presentation/states/server_feature_config.state.dart create mode 100644 mobile-v2/lib/presentation/states/server_info.state.dart create mode 100644 mobile-v2/lib/utils/extensions/number.extension.dart diff --git a/mobile-v2/analysis_options.yaml b/mobile-v2/analysis_options.yaml index fb84821f73..d3d6617eb6 100644 --- a/mobile-v2/analysis_options.yaml +++ b/mobile-v2/analysis_options.yaml @@ -21,6 +21,7 @@ dart_code_metrics: - arguments-ordering: last: - child + - children - avoid-accessing-other-classes-private-members - avoid-assigning-to-static-field - avoid-assignments-as-conditions diff --git a/mobile-v2/assets/i18n/strings.i18n.json b/mobile-v2/assets/i18n/strings.i18n.json index 052403d8c5..6be3ccc84d 100644 --- a/mobile-v2/assets/i18n/strings.i18n.json +++ b/mobile-v2/assets/i18n/strings.i18n.json @@ -38,7 +38,7 @@ }, "logs": { "title": "Logs", - "no_logs": "No logs available", + "no_logs_message": "No logs available", "detail": { "title": "Log Detail", "message_heading": "MESSAGE", @@ -49,6 +49,27 @@ }, "common": { "loading": "Loading", - "copied_long": "Copied to clipboard" + "copied_long": "Copied to clipboard", + "components": { + "grid_empty_message": "Capture memories to start populating the timeline", + "appbar": { + "server_storage": "Server Storage", + "storage_used": "{used: String} of {total: String} used", + "action_logs": "Logs", + "action_settings": "Settings", + "action_signout": "Sign Out", + "footer_documentation": "Documentation", + "footer_github": "Github", + "server_version_label": "Server Version", + "server_url_label": "Server URL", + "server_version_common_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "server_version_major_error": "Server is out of date. Please update to the latest major version.", + "server_version_minor_error": "Server is out of date. Please update to the latest minor version.", + "app_version_major_error": "Mobile App is out of date. Please update to the latest major version.", + "app_version_minor_error": "Mobile App is out of date. Please update to the latest minor version.", + "app_version_ok": "Client and Server are up-to-date", + "app_version_label": "App Version" + } + } } } \ No newline at end of file diff --git a/mobile-v2/lib/domain/interfaces/api/server_api.interface.dart b/mobile-v2/lib/domain/interfaces/api/server_api.interface.dart index 5d3e5e62df..8a7077b331 100644 --- a/mobile-v2/lib/domain/interfaces/api/server_api.interface.dart +++ b/mobile-v2/lib/domain/interfaces/api/server_api.interface.dart @@ -1,5 +1,7 @@ import 'package:immich_mobile/domain/models/server-info/server_config.model.dart'; +import 'package:immich_mobile/domain/models/server-info/server_disk_info.model.dart'; import 'package:immich_mobile/domain/models/server-info/server_features.model.dart'; +import 'package:immich_mobile/domain/models/server-info/server_version.model.dart'; abstract interface class IServerApiRepository { /// Pings and check if server is reachable @@ -10,4 +12,10 @@ abstract interface class IServerApiRepository { /// Fetches the server configuration and settings Future getServerConfig(); + + /// Fetches the server disk info + Future getServerDiskInfo(); + + /// Fetches the server version + Future getServerVersion(); } diff --git a/mobile-v2/lib/domain/models/server-info/server_disk_info.model.dart b/mobile-v2/lib/domain/models/server-info/server_disk_info.model.dart new file mode 100644 index 0000000000..152482769c --- /dev/null +++ b/mobile-v2/lib/domain/models/server-info/server_disk_info.model.dart @@ -0,0 +1,56 @@ +class ServerDiskInfo { + final int diskAvailableInBytes; + final int diskSizeInBytes; + final int diskUseInBytes; + final double diskUsagePercentage; + + const ServerDiskInfo({ + required this.diskAvailableInBytes, + required this.diskSizeInBytes, + required this.diskUseInBytes, + required this.diskUsagePercentage, + }); + + const ServerDiskInfo.initial() + : diskSizeInBytes = -1, + diskUseInBytes = -1, + diskAvailableInBytes = -1, + diskUsagePercentage = -1; + + ServerDiskInfo copyWith({ + int? diskAvailableInBytes, + int? diskSizeInBytes, + int? diskUseInBytes, + double? diskUsagePercentage, + }) { + return ServerDiskInfo( + diskAvailableInBytes: diskAvailableInBytes ?? this.diskAvailableInBytes, + diskSizeInBytes: diskSizeInBytes ?? this.diskSizeInBytes, + diskUseInBytes: diskUseInBytes ?? this.diskUseInBytes, + diskUsagePercentage: diskUsagePercentage ?? this.diskUsagePercentage, + ); + } + + @override + String toString() { + return 'ServerDiskInfo(diskAvailableInBytes: $diskAvailableInBytes, diskSizeInBytes: $diskSizeInBytes, diskUseInBytes: $diskUseInBytes, diskUsagePercentage: $diskUsagePercentage)'; + } + + @override + bool operator ==(covariant ServerDiskInfo other) { + if (identical(this, other)) return true; + + return other.diskAvailableInBytes == diskAvailableInBytes && + other.diskSizeInBytes == diskSizeInBytes && + other.diskUseInBytes == diskUseInBytes && + other.diskUsagePercentage == diskUsagePercentage; + } + + @override + int get hashCode { + return diskAvailableInBytes.hashCode ^ + diskSizeInBytes.hashCode ^ + diskUseInBytes.hashCode ^ + diskUsagePercentage.hashCode; + } +} diff --git a/mobile-v2/lib/domain/models/server-info/server_feature_config.model.dart b/mobile-v2/lib/domain/models/server-info/server_feature_config.model.dart deleted file mode 100644 index 1d83a75627..0000000000 --- a/mobile-v2/lib/domain/models/server-info/server_feature_config.model.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:immich_mobile/domain/models/server-info/server_config.model.dart'; -import 'package:immich_mobile/domain/models/server-info/server_features.model.dart'; - -class ServerFeatureConfig { - final ServerFeatures features; - final ServerConfig config; - - const ServerFeatureConfig({required this.features, required this.config}); - - ServerFeatureConfig copyWith({ - ServerFeatures? features, - ServerConfig? config, - }) { - return ServerFeatureConfig( - features: features ?? this.features, - config: config ?? this.config, - ); - } - - const ServerFeatureConfig.initial() - : features = const ServerFeatures.initial(), - config = const ServerConfig.initial(); - - @override - String toString() => - 'ServerFeatureConfig(features: $features, config: $config)'; - - @override - bool operator ==(covariant ServerFeatureConfig other) { - if (identical(this, other)) return true; - - return other.features == features && other.config == config; - } - - @override - int get hashCode => features.hashCode ^ config.hashCode; -} diff --git a/mobile-v2/lib/domain/models/server-info/server_info.model.dart b/mobile-v2/lib/domain/models/server-info/server_info.model.dart new file mode 100644 index 0000000000..de91f6079b --- /dev/null +++ b/mobile-v2/lib/domain/models/server-info/server_info.model.dart @@ -0,0 +1,56 @@ +import 'package:immich_mobile/domain/models/server-info/server_config.model.dart'; +import 'package:immich_mobile/domain/models/server-info/server_disk_info.model.dart'; +import 'package:immich_mobile/domain/models/server-info/server_features.model.dart'; +import 'package:immich_mobile/domain/models/server-info/server_version.model.dart'; + +class ServerInfo { + final ServerFeatures features; + final ServerConfig config; + final ServerDiskInfo disk; + final ServerVersion version; + + const ServerInfo({ + required this.features, + required this.config, + required this.disk, + required this.version, + }); + + ServerInfo copyWith({ + ServerFeatures? features, + ServerConfig? config, + ServerDiskInfo? disk, + ServerVersion? version, + }) { + return ServerInfo( + features: features ?? this.features, + config: config ?? this.config, + disk: disk ?? this.disk, + version: version ?? this.version, + ); + } + + const ServerInfo.initial() + : features = const ServerFeatures.initial(), + config = const ServerConfig.initial(), + disk = const ServerDiskInfo.initial(), + version = const ServerVersion.initial(); + + @override + String toString() => + 'ServerInfo(features: $features, config: $config, disk: $disk, version: $version)'; + + @override + bool operator ==(covariant ServerInfo other) { + if (identical(this, other)) return true; + + return other.features == features && + other.config == config && + other.disk == disk && + other.version == version; + } + + @override + int get hashCode => + features.hashCode ^ config.hashCode ^ disk.hashCode ^ version.hashCode; +} diff --git a/mobile-v2/lib/domain/models/server-info/server_version.model.dart b/mobile-v2/lib/domain/models/server-info/server_version.model.dart new file mode 100644 index 0000000000..dd419b54b1 --- /dev/null +++ b/mobile-v2/lib/domain/models/server-info/server_version.model.dart @@ -0,0 +1,38 @@ +class ServerVersion { + final int major; + final int minor; + final int patch; + + const ServerVersion({ + required this.major, + required this.minor, + required this.patch, + }); + + ServerVersion copyWith({int? major, int? minor, int? patch}) { + return ServerVersion( + major: major ?? this.major, + minor: minor ?? this.minor, + patch: patch ?? this.patch, + ); + } + + const ServerVersion.initial() + : major = 1, + minor = 1, + patch = 1; + + @override + String toString() => + 'ServerVersion(major: $major, minor: $minor, patch: $patch)'; + + @override + bool operator ==(covariant ServerVersion other) { + if (identical(this, other)) return true; + + return other.major == major && other.minor == minor && other.patch == patch; + } + + @override + int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode; +} diff --git a/mobile-v2/lib/domain/repositories/api/server_api.repository.dart b/mobile-v2/lib/domain/repositories/api/server_api.repository.dart index fbb5203c7d..daaf21b25a 100644 --- a/mobile-v2/lib/domain/repositories/api/server_api.repository.dart +++ b/mobile-v2/lib/domain/repositories/api/server_api.repository.dart @@ -1,6 +1,8 @@ import 'package:immich_mobile/domain/interfaces/api/server_api.interface.dart'; import 'package:immich_mobile/domain/models/server-info/server_config.model.dart'; +import 'package:immich_mobile/domain/models/server-info/server_disk_info.model.dart'; import 'package:immich_mobile/domain/models/server-info/server_features.model.dart'; +import 'package:immich_mobile/domain/models/server-info/server_version.model.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; import 'package:openapi/api.dart' as api; @@ -40,6 +42,32 @@ class ServerApiRepository with LogMixin implements IServerApiRepository { } return null; } + + @override + Future getServerDiskInfo() async { + try { + final storage = await _serverApi.getStorage(); + if (storage != null) { + return _fromStorageDto(storage); + } + } catch (e, s) { + log.e("Exception occured while fetching server disk info", e, s); + } + return null; + } + + @override + Future getServerVersion() async { + try { + final version = await _serverApi.getServerVersion(); + if (version != null) { + return _fromVersionDto(version); + } + } catch (e, s) { + log.e("Exception occured while fetching server version", e, s); + } + return null; + } } ServerConfig _fromConfigDto(api.ServerConfigDto dto) => ServerConfig( @@ -50,3 +78,14 @@ ServerFeatures _fromFeatureDto(api.ServerFeaturesDto dto) => ServerFeatures( hasPasswordLogin: dto.passwordLogin, hasOAuthLogin: dto.oauth, ); + +ServerDiskInfo _fromStorageDto(api.ServerStorageResponseDto dto) => + ServerDiskInfo( + diskAvailableInBytes: dto.diskAvailableRaw, + diskSizeInBytes: dto.diskSizeRaw, + diskUseInBytes: dto.diskUseRaw, + diskUsagePercentage: dto.diskUsagePercentage, + ); + +ServerVersion _fromVersionDto(api.ServerVersionResponseDto dto) => + ServerVersion(major: dto.major, minor: dto.minor, patch: dto.patch_); diff --git a/mobile-v2/lib/domain/services/login.service.dart b/mobile-v2/lib/domain/services/login.service.dart index 5242b0e23a..5bfdef0635 100644 --- a/mobile-v2/lib/domain/services/login.service.dart +++ b/mobile-v2/lib/domain/services/login.service.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/album_sync.service.dart'; import 'package:immich_mobile/domain/services/asset_sync.service.dart'; import 'package:immich_mobile/presentation/states/gallery_permission.state.dart'; -import 'package:immich_mobile/presentation/states/server_feature_config.state.dart'; +import 'package:immich_mobile/presentation/states/server_info.state.dart'; import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/utils/immich_api_client.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; @@ -115,7 +115,7 @@ class LoginService with LogMixin { ServiceLocator.registerPostGlobalStates(); // Fetch server features - await di().getFeatures(); + await di().fetchFeatures(); } Future handlePostLogin() async { @@ -132,6 +132,7 @@ class LoginService with LogMixin { } ServiceLocator.registerCurrentUser(user); + await di().fetchServerDisk(); // sync assets in background unawaited(di().performFullRemoteSyncIsolate(user)); diff --git a/mobile-v2/lib/immich_app.dart b/mobile-v2/lib/immich_app.dart index 863f3681c7..abb4d11a51 100644 --- a/mobile-v2/lib/immich_app.dart +++ b/mobile-v2/lib/immich_app.dart @@ -59,20 +59,41 @@ class _AppThemeBuilder extends StatelessWidget { Widget build(BuildContext context) { // Static colors if (theme != AppTheme.dynamic) { - final lightTheme = AppTheme.generateThemeData(theme.lightSchema); - final darkTheme = AppTheme.generateThemeData(theme.darkSchema); - - return builder(context, lightTheme, darkTheme); + return builder( + context, + theme.generateThemeData(), + theme.generateThemeData(brightness: Brightness.dark), + ); } // Dynamic color builder return DynamicColorBuilder(builder: (lightDynamic, darkDynamic) { - final lightTheme = - AppTheme.generateThemeData(lightDynamic ?? theme.lightSchema); - final darkTheme = - AppTheme.generateThemeData(darkDynamic ?? theme.darkSchema); + if (lightDynamic == null || darkDynamic == null) { + final defaultTheme = AppTheme.blue; + return builder( + context, + defaultTheme.generateThemeData(), + defaultTheme.generateThemeData(brightness: Brightness.dark), + ); + } - return builder(context, lightTheme, darkTheme); + final primaryLight = lightDynamic.primary; + final primaryDark = darkDynamic.primary; + + final lightColor = ColorScheme.fromSeed(seedColor: primaryLight); + final darkColor = ColorScheme.fromSeed( + seedColor: primaryDark, + brightness: Brightness.dark, + ); + + return builder( + context, + AppTheme.generateThemeDataForColorScheme(lightColor), + AppTheme.generateThemeDataForColorScheme( + darkColor, + brightness: Brightness.dark, + ), + ); }); } } diff --git a/mobile-v2/lib/presentation/components/appbar/immich_app_bar.widget.dart b/mobile-v2/lib/presentation/components/appbar/app_bar.widget.dart similarity index 70% rename from mobile-v2/lib/presentation/components/appbar/immich_app_bar.widget.dart rename to mobile-v2/lib/presentation/components/appbar/app_bar.widget.dart index cf01946776..dd62c7dbd9 100644 --- a/mobile-v2/lib/presentation/components/appbar/immich_app_bar.widget.dart +++ b/mobile-v2/lib/presentation/components/appbar/app_bar.widget.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:immich_mobile/presentation/components/appbar/app_bar_dialog.widget.dart'; import 'package:immich_mobile/presentation/components/common/gap.widget.dart'; import 'package:immich_mobile/presentation/components/common/user_avatar.widget.dart'; import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart'; @@ -13,6 +16,11 @@ class ImAppBar extends StatelessWidget implements PreferredSizeWidget { @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); + static void showAppBarDialog(BuildContext context) => unawaited(showDialog( + context: context, + builder: (_) => const ImAppBarDialog(), + )); + @override Widget build(BuildContext context) { return AppBar( @@ -20,7 +28,7 @@ class ImAppBar extends StatelessWidget implements PreferredSizeWidget { title: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - ImLogo(dimension: SizeConstants.xm), + ImLogo(dimension: SizeConstants.xxm), SizedGap.sw(), ImLogoText(fontSize: 20), ], @@ -28,9 +36,12 @@ class ImAppBar extends StatelessWidget implements PreferredSizeWidget { actions: [ Padding( padding: const EdgeInsets.only(right: SizeConstants.m), - child: ImUserAvatar( - user: di().value, - radius: SizeConstants.m, + child: InkWell( + onTap: () => showAppBarDialog(context), + child: ImUserAvatar( + user: di().value, + radius: SizeConstants.m, + ), ), ), ], diff --git a/mobile-v2/lib/presentation/components/appbar/app_bar_dialog.widget.dart b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog.widget.dart new file mode 100644 index 0000000000..554a4cb9e8 --- /dev/null +++ b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog.widget.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/services/login.service.dart'; +import 'package:immich_mobile/i18n/strings.g.dart'; +import 'package:immich_mobile/presentation/components/common/user_avatar.widget.dart'; +import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart'; +import 'package:immich_mobile/presentation/router/router.dart'; +import 'package:immich_mobile/presentation/states/app_info.state.dart'; +import 'package:immich_mobile/presentation/states/current_user.state.dart'; +import 'package:immich_mobile/presentation/states/server_info.state.dart'; +import 'package:immich_mobile/presentation/theme/app_typography.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/constants/size_constants.dart'; +import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; +import 'package:immich_mobile/utils/extensions/color.extension.dart'; +import 'package:immich_mobile/utils/extensions/number.extension.dart'; +import 'package:immich_mobile/utils/immich_api_client.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part 'app_bar_dialog_actions.widget.dart'; +part 'app_bar_dialog_server.widget.dart'; +part 'app_bar_dialog_storage.widget.dart'; +part 'app_bar_dialog_version.widget.dart'; + +class ImAppBarDialog extends StatelessWidget { + const ImAppBarDialog({super.key}); + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: EdgeInsets.only( + left: context.isTablet ? 100 : 15, + top: context.isTablet ? 15 : 0, + right: context.isTablet ? 100 : 15, + bottom: context.isTablet ? 15 : 100, + ), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(SizeConstants.xm)), + ), + alignment: Alignment.center, + child: const Padding( + padding: EdgeInsets.all(SizeConstants.xs), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.only(bottom: SizeConstants.xm), + child: _DialogTitleSection(), + ), + _DialogProfileSection(), + _DialogStorageSection(), + _DialogServerSection(), + _DialogVersionMessage(), + Padding( + padding: EdgeInsets.only(top: 3), + child: _DialogActionLogs(), + ), + _DialogActionSettings(), + _DialogActionSignOut(), + _DialogFooter(), + ], + ), + ), + ), + ); + } +} + +class _DialogTitleSection extends StatelessWidget { + const _DialogTitleSection(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(left: SizeConstants.xs, top: SizeConstants.xs), + child: Stack( + children: [ + Align( + alignment: Alignment.topLeft, + child: InkWell( + onTap: () => unawaited(context.maybePop()), + child: Icon(Symbols.close_rounded, size: SizeConstants.xm), + ), + ), + Center(child: ImLogoText(fontSize: SizeConstants.m)), + ], + ), + ); + } +} + +class _DialogHighlightedSection extends StatelessWidget { + final BorderRadiusGeometry? borderRadius; + final Widget child; + + const _DialogHighlightedSection({this.borderRadius, required this.child}); + + @override + Widget build(BuildContext context) { + // ignore: avoid-wrapping-in-padding + return Padding( + padding: EdgeInsets.only(bottom: 3), + child: Container( + padding: EdgeInsets.symmetric(horizontal: SizeConstants.s), + decoration: BoxDecoration( + color: context.colorScheme.surface, + borderRadius: borderRadius, + ), + child: child, + ), + ); + } +} + +class _DialogProfileSection extends StatelessWidget { + const _DialogProfileSection(); + + @override + Widget build(BuildContext context) { + final user = di().value; + + return _DialogHighlightedSection( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(SizeConstants.xxs), + topRight: Radius.circular(SizeConstants.xxs), + ), + child: ListTile( + leading: ImUserAvatar(user: user), + title: Text( + user.name, + style: AppTypography.titleMedium.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + user.email, + style: AppTypography.titleMedium.copyWith( + color: context.colorScheme.onSurface.darken( + amount: RatioConstants.oneThird, + ), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + minLeadingWidth: SizeConstants.xl, + ), + ); + } +} + +class _DialogFooter extends StatelessWidget { + const _DialogFooter(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: SizeConstants.s), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InkWell( + onTap: () => unawaited(launchUrl( + Uri.parse('https://immich.app'), + mode: LaunchMode.externalApplication, + )), + child: + Text(context.t.common.components.appbar.footer_documentation), + ), + const SizedBox( + width: 20, + child: Text("•", textAlign: TextAlign.center), + ), + InkWell( + onTap: () => unawaited(launchUrl( + Uri.parse('https://github.com/immich-app/immich'), + mode: LaunchMode.externalApplication, + )), + child: Text(context.t.common.components.appbar.footer_github), + ), + ], + ), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_actions.widget.dart b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_actions.widget.dart new file mode 100644 index 0000000000..1c77c99c4d --- /dev/null +++ b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_actions.widget.dart @@ -0,0 +1,73 @@ +part of 'app_bar_dialog.widget.dart'; + +class _DialogAction extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _DialogAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Padding( + padding: EdgeInsets.only(left: 4), + child: Icon(icon, size: 22), + ), + title: Text(label), + dense: true, + visualDensity: VisualDensity.standard, + contentPadding: const EdgeInsets.only(left: 30), + onTap: onTap, + minLeadingWidth: 40, + ); + } +} + +class _DialogActionLogs extends StatelessWidget { + const _DialogActionLogs(); + + @override + Widget build(BuildContext context) { + return _DialogAction( + icon: Symbols.article_rounded, + label: context.t.common.components.appbar.action_logs, + onTap: () => unawaited(context.navigateTo(LogsRoute())), + ); + } +} + +class _DialogActionSettings extends StatelessWidget { + const _DialogActionSettings(); + + @override + Widget build(BuildContext context) { + return _DialogAction( + icon: Symbols.settings_rounded, + label: context.t.common.components.appbar.action_settings, + onTap: () => unawaited(context.navigateTo(SettingsRoute())), + ); + } +} + +class _DialogActionSignOut extends StatelessWidget { + const _DialogActionSignOut(); + + Future _onLogout() async { + await di().logout(); + await di().replaceAll([const LoginRoute()]); + } + + @override + Widget build(BuildContext context) { + return _DialogAction( + icon: Symbols.logout_rounded, + label: context.t.common.components.appbar.action_signout, + onTap: () => unawaited(_onLogout()), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_server.widget.dart b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_server.widget.dart new file mode 100644 index 0000000000..53e8b06457 --- /dev/null +++ b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_server.widget.dart @@ -0,0 +1,123 @@ +part of 'app_bar_dialog.widget.dart'; + +class _DialogServerSection extends StatelessWidget { + const _DialogServerSection(); + + @override + Widget build(BuildContext context) { + return const _DialogHighlightedSection( + child: Padding( + padding: EdgeInsets.symmetric( + vertical: SizeConstants.xxs, + horizontal: SizeConstants.s, + ), + child: Column( + children: [ + _DialogServerAppVersion(), + _DialogServerEntryDivider(), + _DialogServerVersion(), + _DialogServerEntryDivider(), + _DialogServerUrl(), + ], + ), + ), + ); + } +} + +class _DialogServerEntryDivider extends StatelessWidget { + const _DialogServerEntryDivider(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: SizeConstants.s), + child: Divider(thickness: 1), + ); + } +} + +class _DialogServerEntry extends StatelessWidget { + final String label; + final String value; + + const _DialogServerEntry({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: SizeConstants.xs), + child: Text( + label, + style: TextStyle(fontWeight: FontWeight.w400), + ), + ), + ), + Expanded( + flex: 0, + child: Container( + padding: const EdgeInsets.only(right: SizeConstants.xs), + width: context.width * RatioConstants.half, + child: Text( + value, + style: TextStyle( + color: context.colorScheme.onSurface.darken( + amount: RatioConstants.oneThird, + ), + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + textAlign: TextAlign.end, + ), + ), + ), + ], + ); + } +} + +class _DialogServerAppVersion extends StatelessWidget { + const _DialogServerAppVersion(); + + @override + Widget build(BuildContext context) { + final version = di().value.versionString; + + return _DialogServerEntry( + label: context.t.common.components.appbar.app_version_label, + value: version, + ); + } +} + +class _DialogServerVersion extends StatelessWidget { + const _DialogServerVersion(); + + @override + Widget build(BuildContext context) { + final version = di().value.version; + + return _DialogServerEntry( + label: context.t.common.components.appbar.server_version_label, + value: "${version.major}.${version.minor}.${version.patch}", + ); + } +} + +class _DialogServerUrl extends StatelessWidget { + const _DialogServerUrl(); + + @override + Widget build(BuildContext context) { + final serverUrl = di().basePath.replaceAll("/api", ""); + + return _DialogServerEntry( + label: context.t.common.components.appbar.server_url_label, + value: serverUrl, + ); + } +} diff --git a/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_storage.widget.dart b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_storage.widget.dart new file mode 100644 index 0000000000..d533d553fe --- /dev/null +++ b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_storage.widget.dart @@ -0,0 +1,63 @@ +part of 'app_bar_dialog.widget.dart'; + +class _DialogStorageSection extends StatelessWidget { + const _DialogStorageSection(); + + @override + Widget build(BuildContext context) { + final user = di().value; + + final int availableSizeInBytes; + final int usedSizeInBytes; + if (user.quotaSizeInBytes > 0) { + availableSizeInBytes = user.quotaSizeInBytes; + usedSizeInBytes = user.quotaUsageInBytes; + } else { + final storage = di().value.disk; + availableSizeInBytes = storage.diskSizeInBytes; + usedSizeInBytes = storage.diskUseInBytes; + } + + final percentageUsed = usedSizeInBytes / availableSizeInBytes; + + return _DialogHighlightedSection( + child: ListTile( + leading: Padding( + padding: EdgeInsets.only(left: SizeConstants.s), + child: Icon( + Symbols.hard_drive_rounded, + color: context.colorScheme.primary, + ), + ), + title: Text( + context.t.common.components.appbar.server_storage, + style: AppTypography.titleMedium.copyWith( + fontSize: SizeConstants.xxs, + fontWeight: FontWeight.w400, + ), + ), + subtitle: Padding( + padding: EdgeInsets.only(top: SizeConstants.s), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: percentageUsed, + minHeight: 5, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + ), + Padding( + padding: EdgeInsets.only(top: SizeConstants.s), + child: Text(context.t.common.components.appbar.storage_used( + used: usedSizeInBytes.formatAsSize(noOfDecimals: 1), + total: availableSizeInBytes.formatAsSize(), + )), + ), + ], + ), + ), + minLeadingWidth: SizeConstants.xl, + ), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_version.widget.dart b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_version.widget.dart new file mode 100644 index 0000000000..ab47751255 --- /dev/null +++ b/mobile-v2/lib/presentation/components/appbar/app_bar_dialog_version.widget.dart @@ -0,0 +1,68 @@ +part of 'app_bar_dialog.widget.dart'; + +class _DialogVersionMessage extends StatefulWidget { + const _DialogVersionMessage(); + + @override + State createState() => _DialogVersionMessageState(); +} + +class _DialogVersionMessageState extends State<_DialogVersionMessage> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + duration: Durations.medium2, + vsync: this, + ); + late final Animation _animation = CurvedAnimation( + parent: _controller, + curve: Curves.fastEaseInToSlowEaseOut, + ); + + @override + void initState() { + super.initState(); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appInfo = di().value; + + final String message; + if (appInfo.isVersionMismatch) { + message = context.t[appInfo.versionMismatchError]; + } else { + message = context.t.common.components.appbar.app_version_ok; + } + + return SizeTransition( + sizeFactor: _animation, + axisAlignment: 1.0, + child: _DialogHighlightedSection( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(SizeConstants.xxs), + bottomRight: Radius.circular(SizeConstants.xxs), + ), + child: Container( + padding: const EdgeInsets.all(SizeConstants.m), + width: double.infinity, + child: Text( + message, + style: TextStyle( + color: context.colorScheme.primary, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/common/gap.widget.dart b/mobile-v2/lib/presentation/components/common/gap.widget.dart index 8bc3755ce1..2948339a2a 100644 --- a/mobile-v2/lib/presentation/components/common/gap.widget.dart +++ b/mobile-v2/lib/presentation/components/common/gap.widget.dart @@ -7,13 +7,23 @@ class SizedGap extends SizedBox { // Widgets to be used in Column const SizedGap.sh({super.key}) : super(height: SizeConstants.s); + const SizedGap.xsh({super.key}) : super(height: SizeConstants.xs); + const SizedGap.xxsh({super.key}) : super(height: SizeConstants.xxs); const SizedGap.mh({super.key}) : super(height: SizeConstants.m); + const SizedGap.xmh({super.key}) : super(height: SizeConstants.xm); + const SizedGap.xxmh({super.key}) : super(height: SizeConstants.xxm); const SizedGap.lh({super.key}) : super(height: SizeConstants.l); const SizedGap.xlh({super.key}) : super(height: SizeConstants.xl); + const SizedGap.xxlh({super.key}) : super(height: SizeConstants.xxl); // Widgets to be used in Row const SizedGap.sw({super.key}) : super(width: SizeConstants.s); + const SizedGap.xsw({super.key}) : super(width: SizeConstants.xs); + const SizedGap.xxsw({super.key}) : super(width: SizeConstants.xxs); const SizedGap.mw({super.key}) : super(width: SizeConstants.m); + const SizedGap.xmw({super.key}) : super(width: SizeConstants.xm); + const SizedGap.xxmw({super.key}) : super(width: SizeConstants.xxm); const SizedGap.lw({super.key}) : super(width: SizeConstants.l); const SizedGap.xlw({super.key}) : super(width: SizeConstants.xl); + const SizedGap.xxlw({super.key}) : super(width: SizeConstants.xxl); } diff --git a/mobile-v2/lib/presentation/components/common/page_empty.widget.dart b/mobile-v2/lib/presentation/components/common/page_empty.widget.dart new file mode 100644 index 0000000000..78629bfd50 --- /dev/null +++ b/mobile-v2/lib/presentation/components/common/page_empty.widget.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/presentation/components/common/gap.widget.dart'; +import 'package:immich_mobile/utils/constants/size_constants.dart'; +import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; + +class ImPageEmptyIndicator extends StatelessWidget { + final IconData icon; + final String? message; + final Widget? subtitle; + + const ImPageEmptyIndicator({ + super.key, + required this.icon, + this.message, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: SizeConstants.xl, + color: context.colorScheme.primary, + ), + const SizedGap.mh(), + if (message != null) Text(message!), + if (subtitle != null) subtitle!, + ], + ), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/common/user_avatar.widget.dart b/mobile-v2/lib/presentation/components/common/user_avatar.widget.dart index a4fea77a79..0767863730 100644 --- a/mobile-v2/lib/presentation/components/common/user_avatar.widget.dart +++ b/mobile-v2/lib/presentation/components/common/user_avatar.widget.dart @@ -45,7 +45,7 @@ class ImUserAvatar extends StatelessWidget { fit: BoxFit.cover, placeholder: (_, __) => Image.memory( kTransparentImage, - semanticLabel: 'Transparent', + semanticLabel: 'Transparent Image', ), fadeInDuration: const Duration(milliseconds: 300), errorWidget: (_, error, stackTrace) => SizedBox.square(), diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart b/mobile-v2/lib/presentation/components/grid/asset_grid.state.dart similarity index 100% rename from mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart rename to mobile-v2/lib/presentation/components/grid/asset_grid.state.dart diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart b/mobile-v2/lib/presentation/components/grid/asset_grid.widget.dart similarity index 80% rename from mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart rename to mobile-v2/lib/presentation/components/grid/asset_grid.widget.dart index 61a75f14b5..2a3ad45682 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/asset_grid.widget.dart @@ -3,17 +3,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_list_view/flutter_list_view.dart'; import 'package:immich_mobile/domain/models/render_list_element.model.dart'; +import 'package:immich_mobile/i18n/strings.g.dart'; +import 'package:immich_mobile/presentation/components/common/page_empty.widget.dart'; +import 'package:immich_mobile/presentation/components/grid/asset_grid.state.dart'; +import 'package:immich_mobile/presentation/components/grid/asset_render_grid.widget.dart'; import 'package:immich_mobile/presentation/components/grid/draggable_scrollbar.dart'; -import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.state.dart'; -import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart'; -import 'package:immich_mobile/presentation/components/image/immich_thumbnail.widget.dart'; -import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart'; +import 'package:immich_mobile/utils/constants/size_constants.dart'; import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; +import 'package:immich_mobile/utils/extensions/color.extension.dart'; import 'package:intl/intl.dart'; import 'package:material_symbols_icons/symbols.dart'; -part 'immich_asset_grid_header.widget.dart'; -part 'immich_asset_render_grid.widget.dart'; +part 'asset_grid_header.widget.dart'; class ImAssetGrid extends StatefulWidget { /// The padding for the grid @@ -66,6 +67,10 @@ class _ImAssetGridState extends State { builder: (_, state) { final elements = state.renderList.elements; + if (state.renderList.totalCount == 0) { + return const _ImGridEmpty(); + } + // Append padding if required if (widget.topPadding != null && elements.firstOrNull is! RenderListPaddingElement) { @@ -94,7 +99,7 @@ class _ImAssetGridState extends State { RenderListMonthHeaderElement() => _MonthHeader(text: section.header), RenderListDayHeaderElement() => Text(section.header), - RenderListAssetElement() => _StaticGrid( + RenderListAssetElement() => ImStaticGrid( section: section, isDragging: state.isDragScrolling, ), @@ -137,3 +142,21 @@ class _ImAssetGridState extends State { .isAtSameMomentAs(current.renderList.modifiedTime), ); } + +class _ImGridEmpty extends StatelessWidget { + const _ImGridEmpty(); + + @override + Widget build(BuildContext context) { + return ImPageEmptyIndicator( + icon: Symbols.photo_camera_rounded, + subtitle: SizedBox( + width: context.width * RatioConstants.twoThird, + child: Text( + context.t.common.components.grid_empty_message, + textAlign: TextAlign.center, + ), + ), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart b/mobile-v2/lib/presentation/components/grid/asset_grid_header.widget.dart similarity index 87% rename from mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart rename to mobile-v2/lib/presentation/components/grid/asset_grid_header.widget.dart index e6969f60e5..96929f74a0 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/asset_grid_header.widget.dart @@ -1,4 +1,4 @@ -part of 'immich_asset_grid.widget.dart'; +part of 'asset_grid.widget.dart'; class _HeaderText extends StatelessWidget { final String text; @@ -22,7 +22,8 @@ class _HeaderText extends StatelessWidget { const Spacer(), Icon( Symbols.check_circle_rounded, - color: context.colorScheme.onSurface, + color: context.colorScheme.onSurface + .darken(amount: RatioConstants.oneThird), ), ], ), diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_render_grid.widget.dart b/mobile-v2/lib/presentation/components/grid/asset_render_grid.widget.dart similarity index 66% rename from mobile-v2/lib/presentation/components/grid/immich_asset_render_grid.widget.dart rename to mobile-v2/lib/presentation/components/grid/asset_render_grid.widget.dart index c2f1597ed8..1b428a828b 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_render_grid.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/asset_render_grid.widget.dart @@ -1,10 +1,20 @@ -part of 'immich_asset_grid.widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:immich_mobile/domain/models/render_list_element.model.dart'; +import 'package:immich_mobile/presentation/components/grid/asset_grid.state.dart'; +import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart'; +import 'package:immich_mobile/presentation/components/image/immich_thumbnail.widget.dart'; +import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart'; -class _StaticGrid extends StatelessWidget { +class ImStaticGrid extends StatelessWidget { final RenderListAssetElement section; final bool isDragging; - const _StaticGrid({required this.section, required this.isDragging}); + const ImStaticGrid({ + super.key, + required this.section, + required this.isDragging, + }); @override Widget build(BuildContext context) { diff --git a/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart b/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart index 96c96d41b6..ee53ef9347 100644 --- a/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart +++ b/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset.model.dart'; import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart'; +import 'package:immich_mobile/utils/constants/size_constants.dart'; import 'package:material_symbols_icons/symbols.dart'; IconData _getStorageIcon(Asset asset) { @@ -77,7 +78,7 @@ class _PadAlignedIcon extends StatelessWidget { alignment: alignment, child: Icon( icon, - size: 20, + size: SizeConstants.xm, fill: (filled != null && filled!) ? 1 : null, color: Colors.white, ), diff --git a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart index e92c09eedc..3509017641 100644 --- a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart +++ b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart @@ -2,9 +2,9 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:immich_mobile/domain/utils/renderlist_providers.dart'; -import 'package:immich_mobile/presentation/components/appbar/immich_app_bar.widget.dart'; -import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.state.dart'; -import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.widget.dart'; +import 'package:immich_mobile/presentation/components/appbar/app_bar.widget.dart'; +import 'package:immich_mobile/presentation/components/grid/asset_grid.state.dart'; +import 'package:immich_mobile/presentation/components/grid/asset_grid.widget.dart'; import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; @RoutePage() diff --git a/mobile-v2/lib/presentation/modules/login/pages/login.page.dart b/mobile-v2/lib/presentation/modules/login/pages/login.page.dart index 3cd618851f..ec1bb93c38 100644 --- a/mobile-v2/lib/presentation/modules/login/pages/login.page.dart +++ b/mobile-v2/lib/presentation/modules/login/pages/login.page.dart @@ -10,10 +10,11 @@ import 'package:immich_mobile/presentation/modules/login/models/login_page.model 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/presentation/states/app_info.state.dart'; +import 'package:immich_mobile/service_locator.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() @@ -93,17 +94,16 @@ class _LoginPageState extends State ), ); + final version = di().value.versionString; + 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 ?? ''), - ), + DefaultTextStyle.merge( + style: TextStyle(color: context.theme.colorScheme.outline), + child: Text(version), ), TextButton( onPressed: () => unawaited(context.navigateRoot(const LogsRoute())), diff --git a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart index 2ae8398517..d42c9eb734 100644 --- a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart +++ b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/presentation/components/input/text_button.widget.d import 'package:immich_mobile/presentation/components/input/text_form_field.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/states/server_feature_config.state.dart'; +import 'package:immich_mobile/presentation/states/server_info.state.dart'; import 'package:immich_mobile/service_locator.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -132,7 +132,7 @@ class _CredentialsFormState extends State<_CredentialsForm> { builder: (_, isValidationInProgress) => isValidationInProgress ? const ImLoadingIndicator() : ValueListenableBuilder( - valueListenable: di(), + valueListenable: di(), builder: (_, state, __) => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/mobile-v2/lib/presentation/modules/logs/pages/logs.page.dart b/mobile-v2/lib/presentation/modules/logs/pages/logs.page.dart index d2fbc3835e..70e8af73a5 100644 --- a/mobile-v2/lib/presentation/modules/logs/pages/logs.page.dart +++ b/mobile-v2/lib/presentation/modules/logs/pages/logs.page.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/models/log.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/page_empty.widget.dart'; import 'package:immich_mobile/presentation/components/common/skeletonized_future_builder.widget.dart'; import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart'; import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_wrapper.widget.dart'; @@ -183,19 +183,9 @@ class _LogListEmpty extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Symbols.comments_disabled_rounded, - size: 50, - color: context.colorScheme.primary, - ), - const SizedGap.mh(), - Text(context.t.logs.no_logs), - ], - ), + return ImPageEmptyIndicator( + icon: Symbols.comments_disabled_rounded, + message: context.t.logs.no_logs_message, ); } } diff --git a/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart index 42175434ec..03347e21be 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart @@ -19,7 +19,10 @@ class AboutSettingsPage extends StatelessWidget { onTap: () => showLicensePage( context: context, applicationName: context.t.immich, - applicationIcon: const ImLogo(dimension: SizeConstants.xl), + applicationIcon: const Padding( + padding: EdgeInsets.only(top: SizeConstants.s), + child: ImLogo(dimension: SizeConstants.xl), + ), ), ), ); diff --git a/mobile-v2/lib/presentation/router/pages/tab_controller.page.dart b/mobile-v2/lib/presentation/router/pages/tab_controller.page.dart index c3ccab9826..72c2abed75 100644 --- a/mobile-v2/lib/presentation/router/pages/tab_controller.page.dart +++ b/mobile-v2/lib/presentation/router/pages/tab_controller.page.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; import 'package:immich_mobile/i18n/strings.g.dart'; +import 'package:immich_mobile/presentation/components/appbar/app_bar.widget.dart'; import 'package:immich_mobile/presentation/components/common/immich_navigation_rail.widget.dart'; import 'package:immich_mobile/presentation/components/common/user_avatar.widget.dart'; import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart'; @@ -97,12 +98,15 @@ class _TabControllerAdaptiveScaffold extends StatelessWidget { .toList(), selectedIndex: selectedIndex, backgroundColor: navRailTheme.backgroundColor, - leading: ImUserAvatar( - user: di().value, - dimension: SizeConstants.m, - radius: SizeConstants.m, + leading: InkWell( + onTap: () => ImAppBar.showAppBarDialog(context), + child: ImUserAvatar( + user: di().value, + dimension: SizeConstants.m, + radius: SizeConstants.m, + ), ), - trailing: ImLogo(dimension: SizeConstants.xm), + trailing: ImLogo(dimension: SizeConstants.xxm), onDestinationSelected: onSelectedIndexChange, selectedIconTheme: navRailTheme.selectedIconTheme, unselectedIconTheme: navRailTheme.unselectedIconTheme, diff --git a/mobile-v2/lib/presentation/router/router.dart b/mobile-v2/lib/presentation/router/router.dart index 27d1885868..9238fccc9d 100644 --- a/mobile-v2/lib/presentation/router/router.dart +++ b/mobile-v2/lib/presentation/router/router.dart @@ -32,11 +32,11 @@ class AppRouter extends RootStackRouter { List get routes => [ AutoRoute( page: SplashScreenWrapperRoute.page, + initial: true, children: [ AutoRoute(page: SplashScreenRoute.page, initial: true), AutoRoute(page: LoginRoute.page), ], - initial: true, ), AutoRoute(page: LogsWrapperRoute.page, children: [ AutoRoute(page: LogsRoute.page), diff --git a/mobile-v2/lib/presentation/states/app_info.state.dart b/mobile-v2/lib/presentation/states/app_info.state.dart new file mode 100644 index 0000000000..90bf441bcc --- /dev/null +++ b/mobile-v2/lib/presentation/states/app_info.state.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/models/server-info/server_version.model.dart'; +import 'package:immich_mobile/utils/extensions/string.extension.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class AppInfoProvider extends ValueNotifier { + AppInfoProvider() : super(const AppInfo.initial()) { + unawaited(_getAppVersion()); + } + + Future _getAppVersion() async { + final version = (await PackageInfo.fromPlatform()).version; + + final segments = version.split("."); + + final major = segments.firstOrNull ?? '1'; + final minor = segments.elementAtOrNull(1) ?? '0'; + final patch = segments.elementAtOrNull(2)?.replaceAll("-DEBUG", "") ?? '0'; + + value = value.copyWith( + versionString: version, + version: ServerVersion( + major: major.parseInt(), + minor: minor.parseInt(), + patch: patch.parseInt(), + ), + ); + } + + void checkVersionMismatch(ServerVersion? serverVersion) { + if (serverVersion == null) { + value = value.copyWith( + isVersionMismatch: true, + versionMismatchError: + "common.components.appbar.server_version_common_error", + ); + return; + } + + String? errorMessage; + if (value.version.major != serverVersion.major) { + errorMessage = value.version.major > serverVersion.major + ? "common.components.appbar.server_version_major_error" + : "common.components.appbar.app_version_major_error"; + } else if (value.version.minor != serverVersion.minor) { + errorMessage = value.version.minor > serverVersion.minor + ? "common.components.appbar.server_version_minor_error" + : "common.components.appbar.app_version_minor_error"; + } + + value = value.copyWith( + isVersionMismatch: errorMessage != null, + versionMismatchError: errorMessage ?? + "common.components.appbar.server_version_common_error", + ); + } +} + +class AppInfo { + final String versionString; + final ServerVersion version; + final bool isVersionMismatch; + final String versionMismatchError; + + const AppInfo({ + required this.versionString, + required this.version, + required this.isVersionMismatch, + required this.versionMismatchError, + }); + + const AppInfo.initial() + : versionString = '1.0.0', + version = const ServerVersion.initial(), + isVersionMismatch = false, + versionMismatchError = ''; + + AppInfo copyWith({ + String? versionString, + ServerVersion? version, + bool? isVersionMismatch, + String? versionMismatchError, + }) { + return AppInfo( + versionString: versionString ?? this.versionString, + version: version ?? this.version, + isVersionMismatch: isVersionMismatch ?? this.isVersionMismatch, + versionMismatchError: versionMismatchError ?? this.versionMismatchError, + ); + } + + @override + String toString() => + 'AppInfo(versionString: $versionString, isVersionMismatch: $isVersionMismatch, versionMismatchError: $versionMismatchError)'; + + @override + bool operator ==(covariant AppInfo other) { + if (identical(this, other)) return true; + + return other.versionString == versionString && + other.isVersionMismatch == isVersionMismatch && + other.versionMismatchError == versionMismatchError; + } + + @override + int get hashCode => + versionString.hashCode ^ + isVersionMismatch.hashCode ^ + versionMismatchError.hashCode; +} diff --git a/mobile-v2/lib/presentation/states/server_feature_config.state.dart b/mobile-v2/lib/presentation/states/server_feature_config.state.dart deleted file mode 100644 index ed2f38a3b1..0000000000 --- a/mobile-v2/lib/presentation/states/server_feature_config.state.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:immich_mobile/domain/interfaces/api/server_api.interface.dart'; -import 'package:immich_mobile/domain/models/server-info/server_feature_config.model.dart'; - -class ServerFeatureConfigProvider extends ValueNotifier { - final IServerApiRepository _serverApiRepository; - - ServerFeatureConfigProvider({required IServerApiRepository serverApiRepo}) - : _serverApiRepository = serverApiRepo, - super(const ServerFeatureConfig.initial()); - - Future getFeatures() async => - await Future.wait([_getFeatures(), _getConfig()]); - - Future _getFeatures() async { - final features = await _serverApiRepository.getServerFeatures(); - if (features != null) { - value = value.copyWith(features: features); - } - } - - Future _getConfig() async { - final config = await _serverApiRepository.getServerConfig(); - if (config != null) { - value = value.copyWith(config: config); - } - } -} diff --git a/mobile-v2/lib/presentation/states/server_info.state.dart b/mobile-v2/lib/presentation/states/server_info.state.dart new file mode 100644 index 0000000000..d2524812e4 --- /dev/null +++ b/mobile-v2/lib/presentation/states/server_info.state.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/interfaces/api/server_api.interface.dart'; +import 'package:immich_mobile/domain/models/server-info/server_info.model.dart'; +import 'package:immich_mobile/presentation/states/app_info.state.dart'; +import 'package:immich_mobile/service_locator.dart'; + +class ServerInfoProvider extends ValueNotifier { + final IServerApiRepository _serverApiRepository; + + ServerInfoProvider({required IServerApiRepository serverApiRepo}) + : _serverApiRepository = serverApiRepo, + super(const ServerInfo.initial()); + + Future fetchFeatures() async => + await Future.wait([_getFeatures(), _getConfig(), _getVersion()]); + + Future _getFeatures() async { + final features = await _serverApiRepository.getServerFeatures(); + if (features != null) { + value = value.copyWith(features: features); + } + } + + Future _getConfig() async { + final config = await _serverApiRepository.getServerConfig(); + if (config != null) { + value = value.copyWith(config: config); + } + } + + Future _getVersion() async { + final version = await _serverApiRepository.getServerVersion(); + di().checkVersionMismatch(version); + if (version != null) { + value = value.copyWith(version: version); + } + } + + Future fetchServerDisk() async { + final disk = await _serverApiRepository.getServerDiskInfo(); + if (disk != null) { + value = value.copyWith(disk: disk); + } + } +} diff --git a/mobile-v2/lib/presentation/theme/app_colors.dart b/mobile-v2/lib/presentation/theme/app_colors.dart index 7e10fa4fa6..7d3ade2ff2 100644 --- a/mobile-v2/lib/presentation/theme/app_colors.dart +++ b/mobile-v2/lib/presentation/theme/app_colors.dart @@ -4,72 +4,12 @@ import 'package:flutter/material.dart'; abstract final class AppColors { const AppColors(); - /// Blue color - static const ColorScheme blueLight = ColorScheme( + static final blueLight = ColorScheme.fromSeed( + seedColor: Color(0xff1145a4), 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(0xFFF0EFF4), - onSurface: Color(0xff1a1b21), - surfaceContainer: Color(0xfffefbff), - surfaceContainerHigh: Color(0xFFE0E1EA), - surfaceContainerHighest: Color(0xffe0e2ef), - onSurfaceVariant: Color(0xff444651), - 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( + static final blueDark = ColorScheme.fromSeed( + seedColor: Color(0xff001b3d), 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(0xFF15181C), - onSurface: Color(0xffe2e2e9), - surfaceContainer: Color(0xff1a1e22), - surfaceContainerHigh: Color(0xFF2C3138), - surfaceContainerHighest: 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), ); } diff --git a/mobile-v2/lib/presentation/theme/app_theme.dart b/mobile-v2/lib/presentation/theme/app_theme.dart index 42eb1db131..cde622f299 100644 --- a/mobile-v2/lib/presentation/theme/app_theme.dart +++ b/mobile-v2/lib/presentation/theme/app_theme.dart @@ -5,16 +5,24 @@ import 'package:immich_mobile/utils/extensions/material_state.extension.dart'; import 'package:material_symbols_icons/symbols.dart'; enum AppTheme { - blue._(AppColors.blueLight, AppColors.blueDark), - // Fallback color for dynamic theme for non-supported platforms - dynamic._(AppColors.blueLight, AppColors.blueDark); + blue, + dynamic; - final ColorScheme lightSchema; - final ColorScheme darkSchema; + ColorScheme getColorScheme({Brightness brightness = Brightness.light}) { + if (brightness == Brightness.dark) { + return switch (this) { + blue || dynamic => AppColors.blueDark, + }; + } + return switch (this) { + blue || dynamic => AppColors.blueLight, + }; + } - const AppTheme._(this.lightSchema, this.darkSchema); - - static ThemeData generateThemeData(ColorScheme color) { + static ThemeData generateThemeDataForColorScheme( + ColorScheme color, { + Brightness brightness = Brightness.light, + }) { return ThemeData( inputDecorationTheme: InputDecorationTheme( hintStyle: const TextStyle( @@ -39,9 +47,16 @@ enum AppTheme { ), ), colorScheme: color, + brightness: brightness, + dialogBackgroundColor: color.surfaceContainer, primaryColor: color.primary, scaffoldBackgroundColor: color.surface, - iconTheme: const IconThemeData(size: 24, weight: 500, opticalSize: 24), + iconTheme: IconThemeData( + size: 24, + weight: 500, + opticalSize: 24, + color: color.onSurface, + ), textTheme: TextTheme( displayLarge: AppTypography.displayLarge, displayMedium: AppTypography.displayMedium, @@ -64,13 +79,13 @@ enum AppTheme { closeButtonIconBuilder: (_) => Icon(Symbols.close_rounded), ), appBarTheme: AppBarTheme( - backgroundColor: color.surfaceContainerLowest, + backgroundColor: color.surface, iconTheme: IconThemeData(size: 22, color: color.onSurface), titleTextStyle: AppTypography.titleLarge.copyWith(color: color.onSurface), ), navigationBarTheme: NavigationBarThemeData( - backgroundColor: color.surfaceContainer, + backgroundColor: color.surfaceContainerLow, indicatorColor: color.primary, iconTheme: WidgetStateProperty.resolveWith( (Set states) { @@ -82,7 +97,7 @@ enum AppTheme { ), ), navigationRailTheme: NavigationRailThemeData( - backgroundColor: color.surfaceContainer, + backgroundColor: color.surfaceContainerLow, elevation: 3, unselectedIconTheme: IconThemeData( weight: 500, @@ -115,4 +130,13 @@ enum AppTheme { textSelectionTheme: TextSelectionThemeData(cursorColor: color.primary), ); } + + ThemeData generateThemeData({Brightness brightness = Brightness.light}) { + final color = getColorScheme(brightness: brightness); + + return AppTheme.generateThemeDataForColorScheme( + color, + brightness: brightness, + ); + } } diff --git a/mobile-v2/lib/service_locator.dart b/mobile-v2/lib/service_locator.dart index c6c690a04c..774c6ae37f 100644 --- a/mobile-v2/lib/service_locator.dart +++ b/mobile-v2/lib/service_locator.dart @@ -39,10 +39,11 @@ import 'package:immich_mobile/domain/services/hash.service.dart'; import 'package:immich_mobile/domain/services/login.service.dart'; import 'package:immich_mobile/platform/messages.g.dart'; import 'package:immich_mobile/presentation/router/router.dart'; +import 'package:immich_mobile/presentation/states/app_info.state.dart'; import 'package:immich_mobile/presentation/states/app_theme.state.dart'; import 'package:immich_mobile/presentation/states/current_user.state.dart'; import 'package:immich_mobile/presentation/states/gallery_permission.state.dart'; -import 'package:immich_mobile/presentation/states/server_feature_config.state.dart'; +import 'package:immich_mobile/presentation/states/server_info.state.dart'; import 'package:immich_mobile/utils/immich_api_client.dart'; final di = GetIt.I; @@ -156,11 +157,12 @@ abstract final class ServiceLocator { () => AppThemeProvider(settingsService: di()), ); _registerSingleton(GalleryPermissionProvider()); + _registerSingleton(AppInfoProvider()); } static void registerPostGlobalStates() { - _registerLazySingleton( - () => ServerFeatureConfigProvider(serverApiRepo: di()), + _registerLazySingleton( + () => ServerInfoProvider(serverApiRepo: di()), ); } diff --git a/mobile-v2/lib/utils/constants/size_constants.dart b/mobile-v2/lib/utils/constants/size_constants.dart index e95730cfe0..f8c191a70b 100644 --- a/mobile-v2/lib/utils/constants/size_constants.dart +++ b/mobile-v2/lib/utils/constants/size_constants.dart @@ -5,21 +5,31 @@ abstract final class SizeConstants { const SizeConstants._(); static const s = 8.0; + static const xs = 11.0; + static const xxs = 14.0; static const m = 16.0; - static const xm = 25.0; + static const xm = 20.0; + static const xxm = 25.0; static const l = 32.0; - static const xl = 64.0; + static const xl = 48.0; + static const xxl = 64.0; } abstract final class RatioConstants { const RatioConstants._(); + // 0.75 + static const threeFourth = 3 / 4; + // 0.6 + static const twoThird = 2 / 3; // 0.5 - static const oneHalf = 1 / 2; + static const half = 1 / 2; // 0.3 static const oneThird = 1 / 3; // 0.25 static const quarter = 1 / 4; // 0.15 static const halfQuarter = 3 / 20; + // 0.10 + static const oneTenth = 1 / 10; } diff --git a/mobile-v2/lib/utils/extensions/number.extension.dart b/mobile-v2/lib/utils/extensions/number.extension.dart new file mode 100644 index 0000000000..5ddf59ee06 --- /dev/null +++ b/mobile-v2/lib/utils/extensions/number.extension.dart @@ -0,0 +1,24 @@ +import 'dart:math'; + +extension NumberToSizeExtension on num { + String formatAsSize({int noOfDecimals = 0}) { + const List units = [ + 'B', + 'KB', + 'MB', + 'GB', + 'TB', + 'PB', + 'EB', + 'ZB', + 'YB', + ]; + if (this == 0) return '0 B'; + final index = (log(this) / log(1024)).floor(); + final byteIndex = index.clamp(0, units.length - 1); + + final size = (this / pow(1024, byteIndex)).round(); + // ignore: avoid-unsafe-collection-methods + return '${size.toStringAsFixed(noOfDecimals)} ${units[byteIndex]}'; + } +} diff --git a/mobile-v2/lib/utils/extensions/string.extension.dart b/mobile-v2/lib/utils/extensions/string.extension.dart index 93498fd984..062969038b 100644 --- a/mobile-v2/lib/utils/extensions/string.extension.dart +++ b/mobile-v2/lib/utils/extensions/string.extension.dart @@ -1,3 +1,4 @@ extension StringNumberUtils on String { int? tryParseInt() => int.tryParse(this); + int parseInt() => int.parse(this); }