diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..4d60ecb
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,68 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+analyzer:
+ exclude:
+ - 'lib/views/**.dart'
+ - 'lib/components/**.dart'
+ - 'lib/network/**.dart'
+ - 'lib/services/**.dart'
+ - 'lib/utils/**.dart'
+ - 'lib/models/**.dart'
+ - '**/*.g.dart'
+ - '**/*.freezed.dart'
+ - '**/injector/*.config.dart'
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ avoid_print: true
+ sort_constructors_first: true
+ prefer_single_quotes: true
+ prefer_double_quotes: false
+ always_specify_types: true
+ lines_longer_than_80_chars: false
+ avoid_redundant_argument_values: true
+ always_use_package_imports: true
+ require_trailing_commas: true
+ avoid_bool_literals_in_conditional_expressions: true
+ annotate_overrides: true
+ avoid_final_parameters: true
+ avoid_null_checks_in_equality_operators: true
+ avoid_return_types_on_setters: true
+ avoid_returning_null: true
+ avoid_unnecessary_containers: true
+ avoid_unused_constructor_parameters: true
+ await_only_futures: true
+ cascade_invocations: true
+ curly_braces_in_flow_control_structures: true
+ directives_ordering: true
+ empty_constructor_bodies: true
+ empty_catches: true
+ file_names: true
+ use_build_context_synchronously: false
+ join_return_with_assignment: true
+ null_closures: true
+ prefer_adjacent_string_concatenation: true
+ prefer_conditional_assignment: true
+ prefer_const_constructors: true
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
\ No newline at end of file
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
index 449a9f9..106454c 100644
--- a/android/app/src/main/res/values-night/styles.xml
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -5,6 +5,7 @@
- @drawable/launch_background
+ - shortEdges
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index e68b8dc..138d296 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -5,6 +5,7 @@
- @drawable/launch_background
+ - shortEdges
diff --git a/lib/app.dart b/lib/app.dart
index e7f02d0..30f8bc9 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -1,221 +1,215 @@
import 'package:flutter/material.dart';
-import 'package:flutter_easyloading/flutter_easyloading.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
-import 'package:image_picker/image_picker.dart';
-import 'package:piwigo_ng/services/app_providers.dart';
-import 'package:piwigo_ng/services/preferences_service.dart';
-import 'package:piwigo_ng/utils/overscroll_behavior.dart';
-import 'package:piwigo_ng/utils/themes.dart';
-import 'package:piwigo_ng/views/album/album_page.dart';
-import 'package:piwigo_ng/views/album/album_privacy_page.dart';
-import 'package:piwigo_ng/views/album/root_album_page.dart';
-import 'package:piwigo_ng/views/authentication/login_page.dart';
-import 'package:piwigo_ng/views/authentication/login_settings_page.dart';
-import 'package:piwigo_ng/views/image/edit_image_page.dart';
-import 'package:piwigo_ng/views/image/image_favorites_page.dart';
-import 'package:piwigo_ng/views/image/image_page.dart';
-import 'package:piwigo_ng/views/image/image_search_page.dart';
-import 'package:piwigo_ng/views/image/video_player_page.dart';
-import 'package:piwigo_ng/views/settings/auto_upload_page.dart';
-import 'package:piwigo_ng/views/settings/privacy_policy_page.dart';
-import 'package:piwigo_ng/views/settings/select_language_page.dart';
-import 'package:piwigo_ng/views/settings/settings_page.dart';
-import 'package:piwigo_ng/views/unknown_route_page.dart';
-import 'package:piwigo_ng/views/upload/upload_page.dart';
-import 'package:piwigo_ng/views/upload/upload_status_page.dart';
-
-import 'models/image_model.dart';
+import 'package:piwigo_ng/core/router/app_router.dart';
+import 'package:piwigo_ng/core/router/app_routes.dart';
+import 'package:piwigo_ng/core/utils/themes/app_themes.dart';
+import 'package:piwigo_ng/features/authentication/presentation/blocs/session_status/session_status_bloc.dart';
+import 'package:piwigo_ng/features/settings/presentation/blocs/theme/current_theme_bloc.dart';
class App extends StatelessWidget {
- const App({Key? key}) : super(key: key);
+ const App({super.key});
+
+ static const AppRouter _router = AppRouter();
+ static const AppThemes _themes = AppThemes();
- static final GlobalKey scaffoldMessengerKey =
- GlobalKey();
- static final GlobalKey navigatorKey =
- GlobalKey();
+ static final GlobalKey scaffoldMessengerKey = GlobalKey();
+ static final GlobalKey navigatorKey = GlobalKey();
static final GlobalKey appKey = GlobalKey();
@override
Widget build(BuildContext context) {
- return AppProviders(
- builder: (localNotifier, themeNotifier) {
- return MaterialApp(
- title: 'Piwigo NG',
- key: appKey,
- navigatorKey: navigatorKey,
- scaffoldMessengerKey: scaffoldMessengerKey,
- localizationsDelegates: const [
- AppLocalizations.delegate,
- GlobalMaterialLocalizations.delegate,
- GlobalWidgetsLocalizations.delegate,
- GlobalCupertinoLocalizations.delegate,
- ],
- supportedLocales: const [
- Locale('en'),
- Locale('de'),
- Locale('fr'),
- Locale('es'),
- Locale('lt'),
- Locale('sk'),
- Locale('zh'),
- ],
- locale: localNotifier.locale,
- themeMode: themeNotifier.isDark ? ThemeMode.dark : ThemeMode.light,
- darkTheme: darkTheme,
- theme: lightTheme,
- builder: (context, child) {
- EasyLoading.instance
- ..loadingStyle = EasyLoadingStyle.custom
- ..backgroundColor = Theme.of(context).scaffoldBackgroundColor
- ..indicatorColor = Theme.of(context).textTheme.bodyMedium?.color
- ..textColor = Theme.of(context).textTheme.bodyMedium?.color;
- return ScrollConfiguration(
- behavior: OverscrollBehavior(),
- child: EasyLoading.init().call(context, child),
- );
- },
- onGenerateRoute: generateRoute,
- onGenerateInitialRoutes: (String route) {
- return [
- MaterialPageRoute(
- builder: (_) => const LoginPage(autoLogin: true),
- ),
- ];
- },
- initialRoute: '/login',
- );
- },
- );
- }
-}
-
-Route generateRoute(RouteSettings settings) {
- Map arguments = {};
- if (settings.arguments != null) {
- arguments = settings.arguments as Map;
- }
-
- bool isAdmin = appPreferences.getBool(Preferences.isAdminKey) ?? false;
-
- if (settings.name == null) {
- debugPrint("no route name");
- return MaterialPageRoute(
- builder: (_) => UnknownRoutePage(route: settings),
- settings: settings,
- );
- }
-
- switch (settings.name) {
- case LoginPage.routeName:
- return MaterialPageRoute(
- builder: (_) => const LoginPage(),
- settings: settings,
- );
- case LoginSettingsPage.routeName:
- return MaterialPageRoute(
- builder: (_) => LoginSettingsPage(),
- settings: settings,
- );
- case RootAlbumPage.routeName:
- return MaterialPageRoute(
- builder: (_) => RootAlbumPage(
- albumId: arguments['albumId'] ?? 0,
- isAdmin: arguments['isAdmin'] ?? isAdmin,
- ),
- settings: settings,
- );
- case AlbumPage.routeName:
- return MaterialPageRoute(
- builder: (_) => AlbumPage(
- album: arguments['album'],
- isAdmin: arguments['isAdmin'] ?? isAdmin,
- ),
- settings: settings,
- );
- case AlbumPrivacyPage.routeName:
- return MaterialPageRoute(
- builder: (_) => AlbumPrivacyPage(
- album: arguments['album']!,
- ),
- settings: settings,
- );
- case ImageSearchPage.routeName:
- return MaterialPageRoute(
- builder: (_) => ImageSearchPage(
- isAdmin: arguments['isAdmin'] ?? isAdmin,
+ return MultiBlocProvider(
+ providers: >[
+ BlocProvider(
+ create: (BuildContext context) => CurrentThemeBloc()..add(InitThemeEvent()),
),
- settings: settings,
- );
- case ImageFavoritesPage.routeName:
- return MaterialPageRoute(
- builder: (_) => ImageFavoritesPage(
- isAdmin: arguments['isAdmin'] ?? isAdmin,
+ BlocProvider(
+ create: (BuildContext context) => SessionStatusBloc(),
),
- settings: settings,
- );
- case UploadPage.routeName:
- return MaterialPageRoute(
- builder: (_) => UploadPage(
- imageList: arguments["images"] ?? [],
- albumId: arguments["category"],
+ ],
+ child: BlocListener(
+ listener: (BuildContext context, SessionStatusState state) => state.whenOrNull(
+ loggedOut: () => navigatorKey.currentState?.pushNamedAndRemoveUntil(
+ AppRoutes.login,
+ (Route route) => false,
+ ),
),
- settings: settings,
- );
- case UploadStatusPage.routeName:
- return MaterialPageRoute(
- builder: (_) => UploadStatusPage(),
- settings: settings,
- );
- case AutoUploadPage.routeName:
- return MaterialPageRoute(
- builder: (_) => AutoUploadPage(),
- settings: settings,
- );
- case ImagePage.routeName:
- return MaterialPageRoute?>(
- builder: (_) => ImagePage(
- images: arguments['images'] ?? [],
- startId: arguments['startId'],
- album: arguments['album'],
- isAdmin: arguments['isAdmin'] ?? isAdmin,
- ),
- settings: settings,
- );
- case VideoPlayerPage.routeName:
- return MaterialPageRoute(
- builder: (_) => VideoPlayerPage(
- videoUrl: arguments['videoUrl'],
- thumbnailUrl: arguments['thumbnailUrl'],
- ),
- settings: settings,
- );
- case EditImagePage.routeName:
- return MaterialPageRoute(
- builder: (_) => EditImagePage(
- images: arguments['images'] ?? [],
+ child: BlocBuilder(
+ builder: (BuildContext context, CurrentThemeState themeState) {
+ return MaterialApp(
+ // title: 'Piwigo NG',
+ debugShowCheckedModeBanner: false,
+ key: appKey,
+ navigatorKey: navigatorKey,
+ scaffoldMessengerKey: scaffoldMessengerKey,
+ localizationsDelegates: const >[
+ AppLocalizations.delegate,
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ supportedLocales: AppLocalizations.supportedLocales,
+ // locale: localNotifier.locale,
+ themeMode: ThemeMode.light,
+ darkTheme: _themes.dark,
+ theme: _themes.light,
+ // builder: (context, child) {
+ // EasyLoading.instance
+ // ..loadingStyle = EasyLoadingStyle.custom
+ // ..backgroundColor = Theme.of(context).scaffoldBackgroundColor
+ // ..indicatorColor = Theme.of(context).textTheme.bodyMedium?.color
+ // ..textColor = Theme.of(context).textTheme.bodyMedium?.color;
+ // return ScrollConfiguration(
+ // behavior: OverscrollBehavior(),
+ // child: EasyLoading.init().call(context, child),
+ // );
+ // },
+ onGenerateRoute: (RouteSettings settings) => _router.generateRoute(settings),
+ initialRoute: AppRoutes.main,
+ // onGenerateInitialRoutes: (String route) {
+ // return [
+ // MaterialPageRoute(
+ // builder: (_) => const LoginPage(autoLogin: true),
+ // ),
+ // ];
+ // },
+ // initialRoute: '/login',
+ );
+ },
),
- settings: settings,
- );
- case SettingsPage.routeName:
- return MaterialPageRoute(
- builder: (_) => SettingsPage(),
- settings: settings,
- );
- case PrivacyPolicyPage.routeName:
- return MaterialPageRoute(
- builder: (_) => const PrivacyPolicyPage(),
- settings: settings,
- );
- case SelectLanguagePage.routeName:
- return MaterialPageRoute(
- builder: (_) => const SelectLanguagePage(),
- settings: settings,
- );
- default:
- return MaterialPageRoute(
- builder: (_) => UnknownRoutePage(route: settings),
- settings: settings,
- );
+ ),
+ );
}
}
+
+// Route generateRoute(RouteSettings settings) {
+// Map arguments = {};
+// if (settings.arguments != null) {
+// arguments = settings.arguments as Map;
+// }
+//
+// bool isAdmin = appPreferences.getBool(Preferences.isAdminKey) ?? false;
+//
+// if (settings.name == null) {
+// debugPrint("no route name");
+// return MaterialPageRoute(
+// builder: (_) => UnknownRoutePage(route: settings),
+// settings: settings,
+// );
+// }
+//
+// switch (settings.name) {
+// case LoginPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => const LoginPage(),
+// settings: settings,
+// );
+// case LoginSettingsPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => LoginSettingsPage(),
+// settings: settings,
+// );
+// case RootAlbumPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => RootAlbumPage(
+// albumId: arguments['albumId'] ?? 0,
+// isAdmin: arguments['isAdmin'] ?? isAdmin,
+// ),
+// settings: settings,
+// );
+// case AlbumPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => AlbumPage(
+// album: arguments['album'],
+// isAdmin: arguments['isAdmin'] ?? isAdmin,
+// ),
+// settings: settings,
+// );
+// case AlbumPrivacyPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => AlbumPrivacyPage(
+// album: arguments['album']!,
+// ),
+// settings: settings,
+// );
+// case ImageSearchPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => ImageSearchPage(
+// isAdmin: arguments['isAdmin'] ?? isAdmin,
+// ),
+// settings: settings,
+// );
+// case ImageFavoritesPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => ImageFavoritesPage(
+// isAdmin: arguments['isAdmin'] ?? isAdmin,
+// ),
+// settings: settings,
+// );
+// case UploadPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => UploadPage(
+// imageList: arguments["images"] ?? [],
+// albumId: arguments["category"],
+// ),
+// settings: settings,
+// );
+// case UploadStatusPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => UploadStatusPage(),
+// settings: settings,
+// );
+// case AutoUploadPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => AutoUploadPage(),
+// settings: settings,
+// );
+// case ImagePage.routeName:
+// return MaterialPageRoute?>(
+// builder: (_) => ImagePage(
+// images: arguments['images'] ?? [],
+// startId: arguments['startId'],
+// album: arguments['album'],
+// isAdmin: arguments['isAdmin'] ?? isAdmin,
+// ),
+// settings: settings,
+// );
+// case VideoPlayerPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => VideoPlayerPage(
+// videoUrl: arguments['videoUrl'],
+// thumbnailUrl: arguments['thumbnailUrl'],
+// ),
+// settings: settings,
+// );
+// case EditImagePage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => EditImagePage(
+// images: arguments['images'] ?? [],
+// ),
+// settings: settings,
+// );
+// case SettingsPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => SettingsPage(),
+// settings: settings,
+// );
+// case PrivacyPolicyPage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => const PrivacyPolicyPage(),
+// settings: settings,
+// );
+// case SelectLanguagePage.routeName:
+// return MaterialPageRoute(
+// builder: (_) => const SelectLanguagePage(),
+// settings: settings,
+// );
+// default:
+// return MaterialPageRoute(
+// builder: (_) => UnknownRoutePage(route: settings),
+// settings: settings,
+// );
+// }
+// }
diff --git a/lib/components/cards/album_card.dart b/lib/components/cards/album_card.dart
index 0d5aa5b..0623e65 100644
--- a/lib/components/cards/album_card.dart
+++ b/lib/components/cards/album_card.dart
@@ -68,8 +68,8 @@ class AlbumCard extends StatelessWidget {
icon: Icons.edit,
),
AlbumCardAction(
- backgroundColor:
- const Color(0xFF4B4B4B), // Todo: Theme grey action color
+ backgroundColor: const Color(0xFF4B4B4B),
+ // Todo: Theme grey action color
foregroundColor: Colors.white,
autoClose: true,
onPressed: onMove,
@@ -102,8 +102,7 @@ class AlbumCard extends StatelessWidget {
width: ALBUM_ANCHOR_RADIUS * 2.0 + 1.0,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
- border: Border.all(
- color: Theme.of(context).primaryColor, width: 1.0),
+ border: Border.all(color: Theme.of(context).primaryColor, width: 1.0),
),
),
),
@@ -123,8 +122,7 @@ class AlbumCard extends StatelessWidget {
padding: const EdgeInsets.all(8.0).copyWith(right: 0.0),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
- border:
- Border.all(color: Theme.of(context).cardColor, width: 1.0),
+ border: Border.all(color: Theme.of(context).cardColor, width: 1.0),
),
child: AlbumCardContent(
album: album,
@@ -226,8 +224,7 @@ class AlbumCardContent extends StatelessWidget {
album.name,
maxLines: 1,
textAlign: TextAlign.center,
- maxFontSize:
- Theme.of(context).textTheme.titleLarge!.fontSize!,
+ maxFontSize: Theme.of(context).textTheme.titleLarge!.fontSize!,
minFontSize: 10.0,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
@@ -240,8 +237,7 @@ class AlbumCardContent extends StatelessWidget {
child: Builder(builder: (context) {
return AutoSizeText(
album.comment ?? '',
- maxFontSize:
- Theme.of(context).textTheme.bodySmall!.fontSize!,
+ maxFontSize: Theme.of(context).textTheme.bodySmall!.fontSize!,
minFontSize: 10.0,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.bodySmall,
@@ -254,8 +250,7 @@ class AlbumCardContent extends StatelessWidget {
appStrings.imageCount(album.nbTotalImages),
maxLines: 1,
textAlign: TextAlign.center,
- maxFontSize:
- Theme.of(context).textTheme.labelSmall!.fontSize!,
+ maxFontSize: Theme.of(context).textTheme.labelSmall!.fontSize!,
minFontSize: 8.0,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
diff --git a/lib/core/data/datasources/local/preferences_datasource.dart b/lib/core/data/datasources/local/preferences_datasource.dart
new file mode 100644
index 0000000..cce993b
--- /dev/null
+++ b/lib/core/data/datasources/local/preferences_datasource.dart
@@ -0,0 +1,14 @@
+import 'package:piwigo_ng/core/injector/injector.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class PreferencesDatasource {
+ const PreferencesDatasource();
+
+ static final SharedPreferences _prefs = serviceLocator();
+
+ SharedPreferences get instance => _prefs;
+}
+
+mixin AppPreferencesMixin {
+ PreferencesDatasource get prefs => const PreferencesDatasource();
+}
diff --git a/lib/core/data/datasources/local/secure_storage_datasource.dart b/lib/core/data/datasources/local/secure_storage_datasource.dart
new file mode 100644
index 0000000..a2a53a5
--- /dev/null
+++ b/lib/core/data/datasources/local/secure_storage_datasource.dart
@@ -0,0 +1,9 @@
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+
+class SecureStorageDatasource {
+ const SecureStorageDatasource();
+
+ static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
+
+ FlutterSecureStorage get instance => _secureStorage;
+}
diff --git a/lib/core/data/datasources/remote/api_client.dart b/lib/core/data/datasources/remote/api_client.dart
new file mode 100644
index 0000000..3662791
--- /dev/null
+++ b/lib/core/data/datasources/remote/api_client.dart
@@ -0,0 +1,48 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:cookie_jar/cookie_jar.dart';
+import 'package:dio/dio.dart';
+import 'package:dio_cookie_manager/dio_cookie_manager.dart';
+import 'package:flutter/foundation.dart';
+import 'package:piwigo_ng/core/data/datasources/local/preferences_datasource.dart';
+import 'package:piwigo_ng/core/data/datasources/remote/api_interceptor.dart';
+import 'package:piwigo_ng/core/utils/constants/local_key_constants.dart';
+
+class ApiClient {
+ ApiClient()
+ : dio = Dio(BaseOptions())
+ ..interceptors.add(ApiInterceptor())
+ ..interceptors.add(CookieManager(CookieJar()));
+ final Dio dio;
+}
+
+class SSLHttpOverrides extends HttpOverrides with AppPreferencesMixin {
+ @override
+ HttpClient createHttpClient(SecurityContext? context) {
+ return super.createHttpClient(context)..badCertificateCallback = _badCertificateCallback;
+ }
+
+ bool _badCertificateCallback(X509Certificate cert, String host, int port) {
+ if (prefs.instance.getBool(LocalKeyConstants.enableSSLKey) ?? false) {
+ return true;
+ }
+ return false;
+ }
+}
+
+Map tryParseJson(String data) {
+ try {
+ return json.decode(data);
+ } on FormatException catch (_) {
+ if (kDebugMode) {
+ print('Invalid json data');
+ print(data);
+ }
+ int start = data.indexOf('{');
+ int end = data.lastIndexOf('}');
+ String parsedData = data.substring(start, end + 1);
+ if (kDebugMode) print("Parsed : $parsedData");
+ return json.decode(parsedData);
+ }
+}
diff --git a/lib/core/data/datasources/remote/api_interceptor.dart b/lib/core/data/datasources/remote/api_interceptor.dart
new file mode 100644
index 0000000..a8014df
--- /dev/null
+++ b/lib/core/data/datasources/remote/api_interceptor.dart
@@ -0,0 +1,110 @@
+import 'dart:io';
+
+import 'package:dio/dio.dart';
+import 'package:flutter/material.dart';
+import 'package:piwigo_ng/app.dart';
+import 'package:piwigo_ng/components/snackbars.dart';
+import 'package:piwigo_ng/core/data/datasources/local/preferences_datasource.dart';
+import 'package:piwigo_ng/core/extensions/api_preferences_extension.dart';
+import 'package:piwigo_ng/utils/localizations.dart';
+
+class ApiInterceptor extends Interceptor with AppPreferencesMixin {
+ @override
+ void onRequest(
+ RequestOptions options,
+ RequestInterceptorHandler handler,
+ ) async {
+ debugPrint('[${options.method}] ${options.queryParameters['method']}');
+ try {
+ options = _buildRequest(options);
+ return super.onRequest(options, handler);
+ } catch (error) {
+ return handler.reject(
+ DioError(
+ error: error,
+ requestOptions: options,
+ type: DioErrorType.response,
+ ),
+ );
+ }
+ }
+
+ RequestOptions _buildRequest(RequestOptions options) {
+ // Get server url
+ options.baseUrl = prefs.apiBaseUrl!;
+
+ // Server needs Basic authorization
+ options.headers['authorization'] = prefs.apiBasicHeader;
+
+ return options;
+ }
+
+ @override
+ void onResponse(
+ Response response,
+ ResponseInterceptorHandler handler,
+ ) async {
+ debugPrint("[${response.statusCode}] ${response.requestOptions.queryParameters['method']}");
+ return super.onResponse(response, handler);
+ }
+
+ @override
+ void onError(
+ DioError err,
+ ErrorInterceptorHandler handler,
+ ) async {
+ debugPrint("[${err.response?.statusCode}] ${err.requestOptions.queryParameters['method']}");
+ debugPrint('${err.error}\n${err.response?.data}\n${err.stackTrace}');
+
+ switch (err.response?.statusCode) {
+ case 403:
+ App.scaffoldMessengerKey.currentState?.showSnackBar(
+ errorSnackBar(
+ message: appStrings.sessionStatusError_title,
+ icon: Icons.block,
+ ),
+ );
+ break;
+ case null:
+ // Handle invalid SSL
+ if (err.error is HandshakeException) {
+ HandshakeException handshakeError = err.error as HandshakeException;
+ String? message = handshakeError.osError?.message;
+ if (message != null && message.contains('CERTIFICATE_VERIFY_FAILED')) {
+ App.scaffoldMessengerKey.currentState?.showSnackBar(
+ errorSnackBar(
+ message: appStrings.loginCertFailed_title,
+ icon: Icons.bookmark_outlined,
+ ),
+ );
+ break;
+ }
+ }
+
+ // Handle invalid URL
+ if (err.error is SocketException) {
+ SocketException socketError = err.error as SocketException;
+ int? code = socketError.osError?.errorCode;
+ if (code == 7) {
+ App.scaffoldMessengerKey.currentState?.showSnackBar(
+ errorSnackBar(
+ message: appStrings.serverURLerror_title,
+ icon: Icons.public_off,
+ ),
+ );
+ break;
+ }
+ }
+
+ // Unknown server error
+ App.scaffoldMessengerKey.currentState?.showSnackBar(
+ errorSnackBar(
+ message: appStrings.internetErrorGeneral_title,
+ icon: Icons.signal_wifi_connected_no_internet_4,
+ ),
+ );
+ break;
+ }
+ return super.onError(err, handler);
+ }
+}
diff --git a/lib/core/data/datasources/remote/remote_datasource.dart b/lib/core/data/datasources/remote/remote_datasource.dart
new file mode 100644
index 0000000..f02a5b7
--- /dev/null
+++ b/lib/core/data/datasources/remote/remote_datasource.dart
@@ -0,0 +1,192 @@
+import 'dart:convert';
+
+import 'package:dio/dio.dart';
+import 'package:piwigo_ng/core/data/datasources/remote/api_client.dart';
+import 'package:piwigo_ng/core/data/models/api_failure_model.dart';
+import 'package:piwigo_ng/core/enum/request_format_enum.dart';
+import 'package:piwigo_ng/core/errors/failures.dart';
+import 'package:piwigo_ng/core/extensions/api_failure_mapper_extension.dart';
+import 'package:piwigo_ng/core/injector/injector.dart';
+import 'package:piwigo_ng/core/network/network_info.dart';
+import 'package:piwigo_ng/core/utils/constants/api_constants.dart';
+import 'package:piwigo_ng/core/utils/result.dart';
+
+class RemoteDatasource {
+ const RemoteDatasource();
+
+ static final ApiClient _apiClient = serviceLocator();
+ static const RequestFormatEnum _defaultRequestFormat = RequestFormatEnum.json;
+
+ Map _buildQueries({
+ required String method,
+ RequestFormatEnum? format,
+ Map? queryParameters,
+ }) =>
+ {
+ 'format': (format ?? _defaultRequestFormat).value,
+ 'method': method,
+ if (queryParameters != null) ...queryParameters,
+ };
+
+ dynamic _buildData(
+ Map? data,
+ ) {
+ if (data == null) return null;
+ return FormData.fromMap(data);
+ }
+
+ Future> performRequest(
+ Future> Function() request,
+ ) async {
+ try {
+ if (await serviceLocator().isConnected) {
+ Response response = await request();
+
+ Map data = json.decode(response.data as String);
+ if (data['stat'] == 'fail') {
+ return Result.failure(
+ ApiFailureMapper.fromApiFailure(ApiFailureModel.fromJson(data)),
+ );
+ } else if (data['stat'] == 'ok') {
+ return Result.success(data['result']);
+ }
+ return Result.failure(const Failure.unknown());
+ } else {
+ return Result.failure(const Failure.connectivity());
+ }
+ } on DioError catch (error) {
+ return Result.failure(Failure.dio(error));
+ }
+ }
+
+ Future> get({
+ String path = ApiConstants.webServiceUrl,
+ required String method,
+ RequestFormatEnum? format,
+ Map? queryParameters,
+ Options? options,
+ CancelToken? cancelToken,
+ void Function(int, int)? onReceiveProgress,
+ }) async =>
+ performRequest(
+ () => _apiClient.dio.get(
+ path,
+ queryParameters: _buildQueries(
+ format: format,
+ method: method,
+ queryParameters: queryParameters,
+ ),
+ options: options,
+ cancelToken: cancelToken,
+ onReceiveProgress: onReceiveProgress,
+ ),
+ );
+
+ Future> post({
+ String path = ApiConstants.webServiceUrl,
+ required String method,
+ RequestFormatEnum? format,
+ Map? data,
+ Map? queryParameters,
+ Options? options,
+ CancelToken? cancelToken,
+ void Function(int, int)? onSendProgress,
+ void Function(int, int)? onReceiveProgress,
+ }) =>
+ performRequest(
+ () => _apiClient.dio.post(
+ path,
+ data: _buildData(data),
+ queryParameters: _buildQueries(
+ format: format,
+ method: method,
+ queryParameters: queryParameters,
+ ),
+ options: options,
+ cancelToken: cancelToken,
+ onSendProgress: onSendProgress,
+ onReceiveProgress: onReceiveProgress,
+ ),
+ );
+
+ Future> put({
+ String path = ApiConstants.webServiceUrl,
+ required String method,
+ RequestFormatEnum? format,
+ dynamic data,
+ Map? queryParameters,
+ Options? options,
+ CancelToken? cancelToken,
+ void Function(int, int)? onSendProgress,
+ void Function(int, int)? onReceiveProgress,
+ }) =>
+ performRequest(
+ () => _apiClient.dio.put(
+ path,
+ data: data,
+ queryParameters: _buildQueries(
+ format: format,
+ method: method,
+ queryParameters: queryParameters,
+ ),
+ options: options,
+ cancelToken: cancelToken,
+ onSendProgress: onSendProgress,
+ onReceiveProgress: onReceiveProgress,
+ ),
+ );
+
+ Future> delete({
+ String path = ApiConstants.webServiceUrl,
+ required String method,
+ RequestFormatEnum? format,
+ dynamic data,
+ Map? queryParameters,
+ Options? options,
+ CancelToken? cancelToken,
+ void Function(int, int)? onSendProgress,
+ void Function(int, int)? onReceiveProgress,
+ }) =>
+ performRequest(
+ () => _apiClient.dio.delete(
+ path,
+ data: data,
+ queryParameters: _buildQueries(
+ format: format,
+ method: method,
+ queryParameters: queryParameters,
+ ),
+ options: options,
+ cancelToken: cancelToken,
+ ),
+ );
+
+ Future> download({
+ required String path,
+ required String outputPath,
+ required String method,
+ RequestFormatEnum? format,
+ dynamic data,
+ Map? queryParameters,
+ Options? options,
+ String lengthHeader = Headers.contentLengthHeader,
+ CancelToken? cancelToken,
+ void Function(int, int)? onReceiveProgress,
+ }) =>
+ performRequest(
+ () => _apiClient.dio.download(
+ path,
+ outputPath,
+ data: data,
+ queryParameters: _buildQueries(
+ format: format,
+ method: method,
+ queryParameters: queryParameters,
+ ),
+ options: options,
+ lengthHeader: lengthHeader,
+ cancelToken: cancelToken,
+ onReceiveProgress: onReceiveProgress,
+ ),
+ );
+}
diff --git a/lib/core/data/models/api_failure_model.dart b/lib/core/data/models/api_failure_model.dart
new file mode 100644
index 0000000..d7dbf39
--- /dev/null
+++ b/lib/core/data/models/api_failure_model.dart
@@ -0,0 +1,20 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'api_failure_model.g.dart';
+
+@JsonSerializable()
+class ApiFailureModel {
+ factory ApiFailureModel.fromJson(Map json) => _$ApiFailureModelFromJson(json);
+
+ const ApiFailureModel({
+ required this.code,
+ this.message,
+ });
+
+ @JsonKey(name: 'err')
+ final int code;
+
+ final String? message;
+
+ Map toJson() => _$ApiFailureModelToJson(this);
+}
diff --git a/lib/core/data/models/api_failure_model.g.dart b/lib/core/data/models/api_failure_model.g.dart
new file mode 100644
index 0000000..50a4e64
--- /dev/null
+++ b/lib/core/data/models/api_failure_model.g.dart
@@ -0,0 +1,17 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'api_failure_model.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+ApiFailureModel _$ApiFailureModelFromJson(Map json) => ApiFailureModel(
+ code: json['err'] as int,
+ message: json['message'] as String?,
+ );
+
+Map _$ApiFailureModelToJson(ApiFailureModel instance) => {
+ 'err': instance.code,
+ 'message': instance.message,
+ };
diff --git a/lib/core/enum/request_format_enum.dart b/lib/core/enum/request_format_enum.dart
new file mode 100644
index 0000000..3c0bd53
--- /dev/null
+++ b/lib/core/enum/request_format_enum.dart
@@ -0,0 +1,10 @@
+enum RequestFormatEnum {
+ json('json'),
+ xml('rest'),
+ php('php'),
+ xmlRPC('xmlrpc');
+
+ const RequestFormatEnum(this.value);
+
+ final String value;
+}
diff --git a/lib/core/enum/sort_method_enum.dart b/lib/core/enum/sort_method_enum.dart
new file mode 100644
index 0000000..76ab32b
--- /dev/null
+++ b/lib/core/enum/sort_method_enum.dart
@@ -0,0 +1,58 @@
+import 'package:flutter/cupertino.dart';
+import 'package:piwigo_ng/core/extensions/build_context_extension.dart';
+
+enum SortMethodEnum {
+ nameAsc('name ASC'),
+ nameDesc('name DESC'),
+ fileAsc('file ASC'),
+ fileDesc('file DESC'),
+ dateCreatedAsc('date_creation ASC'),
+ dateCreatedDesc('date_creation DESC'),
+ dateAvailableAsc('date_available ASC'),
+ dateAvailableDesc('date_available DESC'),
+ rateAsc('rating_score ASC'),
+ rateDesc('rating_score DESC'),
+ hitAsc('hit ASC'),
+ hitDesc('hit DESC'),
+ random('random'),
+ custom('');
+
+ const SortMethodEnum(this.value);
+
+ final String value;
+}
+
+extension SortMethodExtension on SortMethodEnum {
+ String getLabel(BuildContext context) {
+ switch (this) {
+ case SortMethodEnum.nameAsc:
+ return context.localizations.categorySort_nameAscending;
+ case SortMethodEnum.nameDesc:
+ return context.localizations.categorySort_nameDescending;
+ case SortMethodEnum.fileAsc:
+ return context.localizations.categorySort_fileNameAscending;
+ case SortMethodEnum.fileDesc:
+ return context.localizations.categorySort_fileNameDescending;
+ case SortMethodEnum.dateCreatedAsc:
+ return context.localizations.categorySort_dateCreatedAscending;
+ case SortMethodEnum.dateCreatedDesc:
+ return context.localizations.categorySort_dateCreatedDescending;
+ case SortMethodEnum.dateAvailableAsc:
+ return context.localizations.categorySort_datePostedAscending;
+ case SortMethodEnum.dateAvailableDesc:
+ return context.localizations.categorySort_datePostedDescending;
+ case SortMethodEnum.rateAsc:
+ return context.localizations.categorySort_ratingScoreAscending;
+ case SortMethodEnum.rateDesc:
+ return context.localizations.categorySort_ratingScoreDescending;
+ case SortMethodEnum.hitAsc:
+ return context.localizations.categorySort_visitsAscending;
+ case SortMethodEnum.hitDesc:
+ return context.localizations.categorySort_visitsDescending;
+ case SortMethodEnum.random:
+ return context.localizations.categorySort_random;
+ default:
+ return context.localizations.categorySort_manual;
+ }
+ }
+}
diff --git a/lib/core/errors/failures.dart b/lib/core/errors/failures.dart
new file mode 100644
index 0000000..4e4f978
--- /dev/null
+++ b/lib/core/errors/failures.dart
@@ -0,0 +1,33 @@
+import 'package:dio/dio.dart';
+import 'package:flutter/cupertino.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:piwigo_ng/core/extensions/build_context_extension.dart';
+
+part 'failures.freezed.dart';
+
+@freezed
+class Failure with _$Failure {
+ const Failure._();
+
+ const factory Failure.unknown() = _UnknownFailure;
+
+ const factory Failure.connectivity() = ConnectivityFailure;
+
+ const factory Failure.dio(DioError error) = DioFailure;
+
+ //region Authentication
+ const factory Failure.invalidCredentials() = InvalidCredentialsFailure;
+
+ // const factory Failure.loginError() = LoginErrorFailure;
+ //endregion
+
+ String getMessage(BuildContext context) {
+ switch (runtimeType) {
+ case DioFailure:
+ return (this as DioError).message;
+ case _UnknownFailure:
+ default:
+ return context.localizations.serverUnknownError_message;
+ }
+ }
+}
diff --git a/lib/core/errors/failures.freezed.dart b/lib/core/errors/failures.freezed.dart
new file mode 100644
index 0000000..293b6cb
--- /dev/null
+++ b/lib/core/errors/failures.freezed.dart
@@ -0,0 +1,566 @@
+// coverage:ignore-file
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint
+// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
+
+part of 'failures.dart';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+T _$identity(T value) => value;
+
+final _privateConstructorUsedError = UnsupportedError(
+ 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
+
+/// @nodoc
+mixin _$Failure {
+ @optionalTypeArgs
+ TResult when({
+ required TResult Function() unknown,
+ required TResult Function() connectivity,
+ required TResult Function(DioError error) dio,
+ required TResult Function() invalidCredentials,
+ }) =>
+ throw _privateConstructorUsedError;
+
+ @optionalTypeArgs
+ TResult? whenOrNull({
+ TResult? Function()? unknown,
+ TResult? Function()? connectivity,
+ TResult? Function(DioError error)? dio,
+ TResult? Function()? invalidCredentials,
+ }) =>
+ throw _privateConstructorUsedError;
+ @optionalTypeArgs
+ TResult maybeWhen({
+ TResult Function()? unknown,
+ TResult Function()? connectivity,
+ TResult Function(DioError error)? dio,
+ TResult Function()? invalidCredentials,
+ required TResult orElse(),
+ }) =>
+ throw _privateConstructorUsedError;
+
+ @optionalTypeArgs
+ TResult map({
+ required TResult Function(_UnknownFailure value) unknown,
+ required TResult Function(ConnectivityFailure value) connectivity,
+ required TResult Function(DioFailure value) dio,
+ required TResult Function(InvalidCredentialsFailure value) invalidCredentials,
+ }) =>
+ throw _privateConstructorUsedError;
+
+ @optionalTypeArgs
+ TResult? mapOrNull({
+ TResult? Function(_UnknownFailure value)? unknown,
+ TResult? Function(ConnectivityFailure value)? connectivity,
+ TResult? Function(DioFailure value)? dio,
+ TResult? Function(InvalidCredentialsFailure value)? invalidCredentials,
+ }) =>
+ throw _privateConstructorUsedError;
+ @optionalTypeArgs
+ TResult maybeMap({
+ TResult Function(_UnknownFailure value)? unknown,
+ TResult Function(ConnectivityFailure value)? connectivity,
+ TResult Function(DioFailure value)? dio,
+ TResult Function(InvalidCredentialsFailure value)? invalidCredentials,
+ required TResult orElse(),
+ }) =>
+ throw _privateConstructorUsedError;
+}
+
+/// @nodoc
+abstract class $FailureCopyWith<$Res> {
+ factory $FailureCopyWith(Failure value, $Res Function(Failure) then) = _$FailureCopyWithImpl<$Res, Failure>;
+}
+
+/// @nodoc
+class _$FailureCopyWithImpl<$Res, $Val extends Failure> implements $FailureCopyWith<$Res> {
+ _$FailureCopyWithImpl(this._value, this._then);
+
+// ignore: unused_field
+ final $Val _value;
+// ignore: unused_field
+ final $Res Function($Val) _then;
+}
+
+/// @nodoc
+abstract class _$$UnknownFailureImplCopyWith<$Res> {
+ factory _$$UnknownFailureImplCopyWith(_$UnknownFailureImpl value, $Res Function(_$UnknownFailureImpl) then) =
+ __$$UnknownFailureImplCopyWithImpl<$Res>;
+}
+
+/// @nodoc
+class __$$UnknownFailureImplCopyWithImpl<$Res> extends _$FailureCopyWithImpl<$Res, _$UnknownFailureImpl>
+ implements _$$UnknownFailureImplCopyWith<$Res> {
+ __$$UnknownFailureImplCopyWithImpl(_$UnknownFailureImpl _value, $Res Function(_$UnknownFailureImpl) _then)
+ : super(_value, _then);
+}
+
+/// @nodoc
+
+class _$UnknownFailureImpl extends _UnknownFailure {
+ const _$UnknownFailureImpl() : super._();
+
+ @override
+ String toString() {
+ return 'Failure.unknown()';
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return identical(this, other) || (other.runtimeType == runtimeType && other is _$UnknownFailureImpl);
+ }
+
+ @override
+ int get hashCode => runtimeType.hashCode;
+
+ @override
+ @optionalTypeArgs
+ TResult when({
+ required TResult Function() unknown,
+ required TResult Function() connectivity,
+ required TResult Function(DioError error) dio,
+ required TResult Function() invalidCredentials,
+ }) {
+ return unknown();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? whenOrNull({
+ TResult? Function()? unknown,
+ TResult? Function()? connectivity,
+ TResult? Function(DioError error)? dio,
+ TResult? Function()? invalidCredentials,
+ }) {
+ return unknown?.call();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeWhen({
+ TResult Function()? unknown,
+ TResult Function()? connectivity,
+ TResult Function(DioError error)? dio,
+ TResult Function()? invalidCredentials,
+ required TResult orElse(),
+ }) {
+ if (unknown != null) {
+ return unknown();
+ }
+ return orElse();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult map({
+ required TResult Function(_UnknownFailure value) unknown,
+ required TResult Function(ConnectivityFailure value) connectivity,
+ required TResult Function(DioFailure value) dio,
+ required TResult Function(InvalidCredentialsFailure value) invalidCredentials,
+ }) {
+ return unknown(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? mapOrNull({
+ TResult? Function(_UnknownFailure value)? unknown,
+ TResult? Function(ConnectivityFailure value)? connectivity,
+ TResult? Function(DioFailure value)? dio,
+ TResult? Function(InvalidCredentialsFailure value)? invalidCredentials,
+ }) {
+ return unknown?.call(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeMap({
+ TResult Function(_UnknownFailure value)? unknown,
+ TResult Function(ConnectivityFailure value)? connectivity,
+ TResult Function(DioFailure value)? dio,
+ TResult Function(InvalidCredentialsFailure value)? invalidCredentials,
+ required TResult orElse(),
+ }) {
+ if (unknown != null) {
+ return unknown(this);
+ }
+ return orElse();
+ }
+}
+
+abstract class _UnknownFailure extends Failure {
+ const factory _UnknownFailure() = _$UnknownFailureImpl;
+ const _UnknownFailure._() : super._();
+}
+
+/// @nodoc
+abstract class _$$ConnectivityFailureImplCopyWith<$Res> {
+ factory _$$ConnectivityFailureImplCopyWith(
+ _$ConnectivityFailureImpl value, $Res Function(_$ConnectivityFailureImpl) then) =
+ __$$ConnectivityFailureImplCopyWithImpl<$Res>;
+}
+
+/// @nodoc
+class __$$ConnectivityFailureImplCopyWithImpl<$Res> extends _$FailureCopyWithImpl<$Res, _$ConnectivityFailureImpl>
+ implements _$$ConnectivityFailureImplCopyWith<$Res> {
+ __$$ConnectivityFailureImplCopyWithImpl(
+ _$ConnectivityFailureImpl _value, $Res Function(_$ConnectivityFailureImpl) _then)
+ : super(_value, _then);
+}
+
+/// @nodoc
+
+class _$ConnectivityFailureImpl extends ConnectivityFailure {
+ const _$ConnectivityFailureImpl() : super._();
+
+ @override
+ String toString() {
+ return 'Failure.connectivity()';
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return identical(this, other) || (other.runtimeType == runtimeType && other is _$ConnectivityFailureImpl);
+ }
+
+ @override
+ int get hashCode => runtimeType.hashCode;
+
+ @override
+ @optionalTypeArgs
+ TResult when({
+ required TResult Function() unknown,
+ required TResult Function() connectivity,
+ required TResult Function(DioError error) dio,
+ required TResult Function() invalidCredentials,
+ }) {
+ return connectivity();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? whenOrNull({
+ TResult? Function()? unknown,
+ TResult? Function()? connectivity,
+ TResult? Function(DioError error)? dio,
+ TResult? Function()? invalidCredentials,
+ }) {
+ return connectivity?.call();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeWhen({
+ TResult Function()? unknown,
+ TResult Function()? connectivity,
+ TResult Function(DioError error)? dio,
+ TResult Function()? invalidCredentials,
+ required TResult orElse(),
+ }) {
+ if (connectivity != null) {
+ return connectivity();
+ }
+ return orElse();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult map({
+ required TResult Function(_UnknownFailure value) unknown,
+ required TResult Function(ConnectivityFailure value) connectivity,
+ required TResult Function(DioFailure value) dio,
+ required TResult Function(InvalidCredentialsFailure value) invalidCredentials,
+ }) {
+ return connectivity(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? mapOrNull({
+ TResult? Function(_UnknownFailure value)? unknown,
+ TResult? Function(ConnectivityFailure value)? connectivity,
+ TResult? Function(DioFailure value)? dio,
+ TResult? Function(InvalidCredentialsFailure value)? invalidCredentials,
+ }) {
+ return connectivity?.call(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeMap({
+ TResult Function(_UnknownFailure value)? unknown,
+ TResult Function(ConnectivityFailure value)? connectivity,
+ TResult Function(DioFailure value)? dio,
+ TResult Function(InvalidCredentialsFailure value)? invalidCredentials,
+ required TResult orElse(),
+ }) {
+ if (connectivity != null) {
+ return connectivity(this);
+ }
+ return orElse();
+ }
+}
+
+abstract class ConnectivityFailure extends Failure {
+ const factory ConnectivityFailure() = _$ConnectivityFailureImpl;
+ const ConnectivityFailure._() : super._();
+}
+
+/// @nodoc
+abstract class _$$DioFailureImplCopyWith<$Res> {
+ factory _$$DioFailureImplCopyWith(_$DioFailureImpl value, $Res Function(_$DioFailureImpl) then) =
+ __$$DioFailureImplCopyWithImpl<$Res>;
+ @useResult
+ $Res call({DioError error});
+}
+
+/// @nodoc
+class __$$DioFailureImplCopyWithImpl<$Res> extends _$FailureCopyWithImpl<$Res, _$DioFailureImpl>
+ implements _$$DioFailureImplCopyWith<$Res> {
+ __$$DioFailureImplCopyWithImpl(_$DioFailureImpl _value, $Res Function(_$DioFailureImpl) _then) : super(_value, _then);
+
+ @pragma('vm:prefer-inline')
+ @override
+ $Res call({
+ Object? error = null,
+ }) {
+ return _then(_$DioFailureImpl(
+ null == error
+ ? _value.error
+ : error // ignore: cast_nullable_to_non_nullable
+ as DioError,
+ ));
+ }
+}
+
+/// @nodoc
+
+class _$DioFailureImpl extends DioFailure {
+ const _$DioFailureImpl(this.error) : super._();
+
+ @override
+ final DioError error;
+
+ @override
+ String toString() {
+ return 'Failure.dio(error: $error)';
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return identical(this, other) ||
+ (other.runtimeType == runtimeType &&
+ other is _$DioFailureImpl &&
+ (identical(other.error, error) || other.error == error));
+ }
+
+ @override
+ int get hashCode => Object.hash(runtimeType, error);
+
+ @JsonKey(ignore: true)
+ @override
+ @pragma('vm:prefer-inline')
+ _$$DioFailureImplCopyWith<_$DioFailureImpl> get copyWith =>
+ __$$DioFailureImplCopyWithImpl<_$DioFailureImpl>(this, _$identity);
+
+ @override
+ @optionalTypeArgs
+ TResult when({
+ required TResult Function() unknown,
+ required TResult Function() connectivity,
+ required TResult Function(DioError error) dio,
+ required TResult Function() invalidCredentials,
+ }) {
+ return dio(error);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? whenOrNull({
+ TResult? Function()? unknown,
+ TResult? Function()? connectivity,
+ TResult? Function(DioError error)? dio,
+ TResult? Function()? invalidCredentials,
+ }) {
+ return dio?.call(error);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeWhen({
+ TResult Function()? unknown,
+ TResult Function()? connectivity,
+ TResult Function(DioError error)? dio,
+ TResult Function()? invalidCredentials,
+ required TResult orElse(),
+ }) {
+ if (dio != null) {
+ return dio(error);
+ }
+ return orElse();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult map({
+ required TResult Function(_UnknownFailure value) unknown,
+ required TResult Function(ConnectivityFailure value) connectivity,
+ required TResult Function(DioFailure value) dio,
+ required TResult Function(InvalidCredentialsFailure value) invalidCredentials,
+ }) {
+ return dio(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? mapOrNull({
+ TResult? Function(_UnknownFailure value)? unknown,
+ TResult? Function(ConnectivityFailure value)? connectivity,
+ TResult? Function(DioFailure value)? dio,
+ TResult? Function(InvalidCredentialsFailure value)? invalidCredentials,
+ }) {
+ return dio?.call(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeMap({
+ TResult Function(_UnknownFailure value)? unknown,
+ TResult Function(ConnectivityFailure value)? connectivity,
+ TResult Function(DioFailure value)? dio,
+ TResult Function(InvalidCredentialsFailure value)? invalidCredentials,
+ required TResult orElse(),
+ }) {
+ if (dio != null) {
+ return dio(this);
+ }
+ return orElse();
+ }
+}
+
+abstract class DioFailure extends Failure {
+ const factory DioFailure(final DioError error) = _$DioFailureImpl;
+ const DioFailure._() : super._();
+
+ DioError get error;
+ @JsonKey(ignore: true)
+ _$$DioFailureImplCopyWith<_$DioFailureImpl> get copyWith => throw _privateConstructorUsedError;
+}
+
+/// @nodoc
+abstract class _$$InvalidCredentialsFailureImplCopyWith<$Res> {
+ factory _$$InvalidCredentialsFailureImplCopyWith(
+ _$InvalidCredentialsFailureImpl value, $Res Function(_$InvalidCredentialsFailureImpl) then) =
+ __$$InvalidCredentialsFailureImplCopyWithImpl<$Res>;
+}
+
+/// @nodoc
+class __$$InvalidCredentialsFailureImplCopyWithImpl<$Res>
+ extends _$FailureCopyWithImpl<$Res, _$InvalidCredentialsFailureImpl>
+ implements _$$InvalidCredentialsFailureImplCopyWith<$Res> {
+ __$$InvalidCredentialsFailureImplCopyWithImpl(
+ _$InvalidCredentialsFailureImpl _value, $Res Function(_$InvalidCredentialsFailureImpl) _then)
+ : super(_value, _then);
+}
+
+/// @nodoc
+
+class _$InvalidCredentialsFailureImpl extends InvalidCredentialsFailure {
+ const _$InvalidCredentialsFailureImpl() : super._();
+
+ @override
+ String toString() {
+ return 'Failure.invalidCredentials()';
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return identical(this, other) || (other.runtimeType == runtimeType && other is _$InvalidCredentialsFailureImpl);
+ }
+
+ @override
+ int get hashCode => runtimeType.hashCode;
+
+ @override
+ @optionalTypeArgs
+ TResult when({
+ required TResult Function() unknown,
+ required TResult Function() connectivity,
+ required TResult Function(DioError error) dio,
+ required TResult Function() invalidCredentials,
+ }) {
+ return invalidCredentials();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? whenOrNull({
+ TResult? Function()? unknown,
+ TResult? Function()? connectivity,
+ TResult? Function(DioError error)? dio,
+ TResult? Function()? invalidCredentials,
+ }) {
+ return invalidCredentials?.call();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeWhen({
+ TResult Function()? unknown,
+ TResult Function()? connectivity,
+ TResult Function(DioError error)? dio,
+ TResult Function()? invalidCredentials,
+ required TResult orElse(),
+ }) {
+ if (invalidCredentials != null) {
+ return invalidCredentials();
+ }
+ return orElse();
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult map({
+ required TResult Function(_UnknownFailure value) unknown,
+ required TResult Function(ConnectivityFailure value) connectivity,
+ required TResult Function(DioFailure value) dio,
+ required TResult Function(InvalidCredentialsFailure value) invalidCredentials,
+ }) {
+ return invalidCredentials(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult? mapOrNull({
+ TResult? Function(_UnknownFailure value)? unknown,
+ TResult? Function(ConnectivityFailure value)? connectivity,
+ TResult? Function(DioFailure value)? dio,
+ TResult? Function(InvalidCredentialsFailure value)? invalidCredentials,
+ }) {
+ return invalidCredentials?.call(this);
+ }
+
+ @override
+ @optionalTypeArgs
+ TResult maybeMap({
+ TResult Function(_UnknownFailure value)? unknown,
+ TResult Function(ConnectivityFailure value)? connectivity,
+ TResult Function(DioFailure value)? dio,
+ TResult Function(InvalidCredentialsFailure value)? invalidCredentials,
+ required TResult orElse(),
+ }) {
+ if (invalidCredentials != null) {
+ return invalidCredentials(this);
+ }
+ return orElse();
+ }
+}
+
+abstract class InvalidCredentialsFailure extends Failure {
+ const factory InvalidCredentialsFailure() = _$InvalidCredentialsFailureImpl;
+ const InvalidCredentialsFailure._() : super._();
+}
diff --git a/lib/core/extensions/album_preferences_extension.dart b/lib/core/extensions/album_preferences_extension.dart
new file mode 100644
index 0000000..ec6c519
--- /dev/null
+++ b/lib/core/extensions/album_preferences_extension.dart
@@ -0,0 +1,22 @@
+import 'package:piwigo_ng/core/data/datasources/local/preferences_datasource.dart';
+import 'package:piwigo_ng/core/injector/injector.dart';
+import 'package:piwigo_ng/core/utils/constants/local_key_constants.dart';
+import 'package:piwigo_ng/core/utils/constants/settings_constants.dart';
+import 'package:piwigo_ng/features/images/data/enums/image_size_enum.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+extension AlbumPreferencesExtension on PreferencesDatasource {
+ SharedPreferences get _prefs => serviceLocator();
+
+ ImageSizeEnum get getAlbumThumbnailSize => ImageSizeEnum.fromJson(
+ _prefs.getString(LocalKeyConstants.albumThumbnailSizeKey) ?? SettingsConstants.defaultAlbumThumbnailSize.value,
+ );
+
+ ImageSizeEnum get getImageThumbnailSize => ImageSizeEnum.fromJson(
+ _prefs.getString(LocalKeyConstants.imageThumbnailSizeKey) ?? SettingsConstants.defaultImageThumbnailSize.value,
+ );
+
+ bool get getShowThumbnailTitle {
+ return _prefs.getBool(LocalKeyConstants.showThumbnailTitleKey) ?? SettingsConstants.defaultShowThumbnailTitle;
+ }
+}
diff --git a/lib/core/extensions/api_failure_mapper_extension.dart b/lib/core/extensions/api_failure_mapper_extension.dart
new file mode 100644
index 0000000..6f6e319
--- /dev/null
+++ b/lib/core/extensions/api_failure_mapper_extension.dart
@@ -0,0 +1,16 @@
+import 'package:piwigo_ng/core/data/models/api_failure_model.dart';
+import 'package:piwigo_ng/core/errors/failures.dart';
+import 'package:piwigo_ng/core/utils/constants/api_errors.dart';
+
+extension ApiFailureMapper on Failure {
+ static Failure fromApiFailure(ApiFailureModel failure) {
+ switch (failure.code) {
+ case ApiErrors.invalidCredentialsCode:
+ return const Failure.invalidCredentials();
+ case ApiErrors.missingUsernameCode:
+ return const Failure.invalidCredentials();
+ default:
+ return const Failure.unknown();
+ }
+ }
+}
diff --git a/lib/core/extensions/api_preferences_extension.dart b/lib/core/extensions/api_preferences_extension.dart
new file mode 100644
index 0000000..02f3005
--- /dev/null
+++ b/lib/core/extensions/api_preferences_extension.dart
@@ -0,0 +1,18 @@
+import 'dart:convert';
+
+import 'package:piwigo_ng/core/data/datasources/local/preferences_datasource.dart';
+import 'package:piwigo_ng/core/utils/constants/api_constants.dart';
+import 'package:piwigo_ng/core/utils/constants/local_key_constants.dart';
+
+extension ApiPreferencesExtension on PreferencesDatasource {
+ String? get apiBaseUrl => instance.getString(LocalKeyConstants.serverUrlKey);
+
+ bool get isBasicAuthEnabled => instance.getBool(LocalKeyConstants.enableBasicAuthKey) ?? false;
+
+ String? get apiBasicHeader {
+ if (!isBasicAuthEnabled) return null;
+ String username = instance.getString(LocalKeyConstants.basicUsernameKey) ?? '';
+ String password = instance.getString(LocalKeyConstants.basicPasswordKey) ?? '';
+ return '${ApiConstants.basicPrefix} ${base64.encode(utf8.encode('$username:$password'))}';
+ }
+}
diff --git a/lib/core/extensions/build_context_extension.dart b/lib/core/extensions/build_context_extension.dart
new file mode 100644
index 0000000..7216dc0
--- /dev/null
+++ b/lib/core/extensions/build_context_extension.dart
@@ -0,0 +1,26 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+extension BuildContextExtension on BuildContext {
+ NavigatorState get navigator => Navigator.of(this);
+
+ AppLocalizations get localizations => AppLocalizations.of(this)!;
+
+ Size screenSize({bool safeArea = false}) {
+ Size size = MediaQuery.of(this).size;
+ if (!safeArea) return size;
+ EdgeInsets safeAreaPadding = screenPadding;
+ return Size(
+ size.width - safeAreaPadding.horizontal,
+ size.height - safeAreaPadding.vertical,
+ );
+ }
+
+ EdgeInsets get screenPadding => MediaQuery.of(this).viewPadding;
+
+ //region Themes
+ ThemeData get theme => Theme.of(this);
+
+ TextTheme get textStyles => Theme.of(this).textTheme;
+//endregion
+}
diff --git a/lib/core/extensions/string_extension.dart b/lib/core/extensions/string_extension.dart
new file mode 100644
index 0000000..49ec465
--- /dev/null
+++ b/lib/core/extensions/string_extension.dart
@@ -0,0 +1,3 @@
+extension StringExtension on String {
+ String capitalize() => '${this[0].toUpperCase()}${substring(1).toLowerCase()}';
+}
diff --git a/lib/core/extensions/theme_mode_extension.dart b/lib/core/extensions/theme_mode_extension.dart
new file mode 100644
index 0000000..c014959
--- /dev/null
+++ b/lib/core/extensions/theme_mode_extension.dart
@@ -0,0 +1,27 @@
+import 'package:flutter/material.dart';
+
+extension ThemeModeExtension on ThemeMode {
+ String toJson() {
+ switch (this) {
+ case ThemeMode.system:
+ return 'system';
+ case ThemeMode.light:
+ return 'light';
+ case ThemeMode.dark:
+ return 'dark';
+ }
+ }
+}
+
+ThemeMode themeModeFromJson(String? json) {
+ switch (json) {
+ case 'system':
+ return ThemeMode.system;
+ case 'light':
+ return ThemeMode.light;
+ case 'dark':
+ return ThemeMode.dark;
+ default:
+ return ThemeMode.system;
+ }
+}
diff --git a/lib/core/injector/injector.dart b/lib/core/injector/injector.dart
new file mode 100644
index 0000000..5cb693c
--- /dev/null
+++ b/lib/core/injector/injector.dart
@@ -0,0 +1,21 @@
+import 'package:connectivity_plus/connectivity_plus.dart';
+import 'package:get_it/get_it.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:piwigo_ng/core/data/datasources/remote/api_client.dart';
+import 'package:piwigo_ng/core/network/network_info.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+final GetIt serviceLocator = GetIt.instance;
+
+Future init() async {
+ // Core
+ serviceLocator
+ ..registerLazySingleton(() => NetworkInfoImpl(serviceLocator()))
+ ..registerLazySingleton(() => Connectivity())
+ ..registerLazySingleton(() => ApiClient())
+ ..registerLazySingletonAsync(() => SharedPreferences.getInstance())
+ ..registerLazySingletonAsync(() => PackageInfo.fromPlatform());
+
+ await serviceLocator.isReady();
+ await serviceLocator.isReady();
+}
diff --git a/lib/core/network/network_info.dart b/lib/core/network/network_info.dart
new file mode 100644
index 0000000..37b20f4
--- /dev/null
+++ b/lib/core/network/network_info.dart
@@ -0,0 +1,17 @@
+import 'package:connectivity_plus/connectivity_plus.dart';
+
+abstract class NetworkInfo {
+ Future get isConnected;
+}
+
+class NetworkInfoImpl implements NetworkInfo {
+ NetworkInfoImpl(this.connectivity);
+
+ final Connectivity connectivity;
+
+ @override
+ Future get isConnected async {
+ final ConnectivityResult connectivityResult = await connectivity.checkConnectivity();
+ return connectivityResult != ConnectivityResult.none;
+ }
+}
diff --git a/lib/core/presentation/widgets/animated/collapsible_widget.dart b/lib/core/presentation/widgets/animated/collapsible_widget.dart
new file mode 100644
index 0000000..e1197be
--- /dev/null
+++ b/lib/core/presentation/widgets/animated/collapsible_widget.dart
@@ -0,0 +1,32 @@
+import 'package:flutter/material.dart';
+import 'package:piwigo_ng/core/utils/constants/ui_constants.dart';
+
+class CollapsibleWidget extends StatelessWidget {
+ const CollapsibleWidget({
+ super.key,
+ required this.child,
+ this.expanded = true,
+ this.alignment = Alignment.topCenter,
+ });
+
+ final bool expanded;
+ final AlignmentGeometry alignment;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedSize(
+ duration: UIConstants.animationDurationShort,
+ curve: Curves.ease,
+ alignment: Alignment.topCenter,
+ child: Builder(
+ builder: (BuildContext context) {
+ if (expanded) return child;
+ return SizedBox.fromSize(
+ size: const Size.fromHeight(0.0),
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/lib/core/presentation/widgets/app_bars/expanded_app_bar.dart b/lib/core/presentation/widgets/app_bars/expanded_app_bar.dart
new file mode 100644
index 0000000..095b11d
--- /dev/null
+++ b/lib/core/presentation/widgets/app_bars/expanded_app_bar.dart
@@ -0,0 +1,78 @@
+import 'package:flutter/material.dart';
+import 'package:piwigo_ng/core/utils/constants/ui_constants.dart';
+
+class ExpandedAppBar extends AnimatedWidget {
+ const ExpandedAppBar({
+ super.key,
+ required this.scrollController,
+ required this.title,
+ this.leading,
+ this.actions,
+ this.child,
+ }) : super(listenable: scrollController);
+
+ final ScrollController scrollController;
+ final String title;
+ final Widget? child;
+ final Widget? leading;
+ final List? actions;
+
+ static const double _expandedHeight = kToolbarHeight * 2;
+ static const double _opacityScale = 0.3;
+
+ double get _titleOpacity {
+ if (scrollController.hasClients) {
+ if (scrollController.offset > _expandedHeight * _opacityScale) {
+ return 0.0;
+ }
+ return (_expandedHeight * _opacityScale - scrollController.offset) / (_expandedHeight * _opacityScale);
+ }
+ return 1.0;
+ }
+
+ double get _horizontalTitlePadding {
+ double basePadding = UIConstants.paddingMedium;
+ double delta = (kToolbarHeight - basePadding) / basePadding;
+
+ if (scrollController.hasClients) {
+ if (scrollController.offset > (_expandedHeight - kToolbarHeight)) {
+ // In case 0% of the expanded height is viewed
+ return basePadding * delta + basePadding;
+ }
+
+ // In case 0%-100% of the expanded height is viewed
+ double scrollDelta = (_expandedHeight - scrollController.offset) / _expandedHeight;
+ double scrollPercent = (scrollDelta * 2 - 1);
+ return (1 - scrollPercent) * delta * basePadding + basePadding;
+ }
+
+ return basePadding;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SliverAppBar(
+ leading: leading,
+ pinned: true,
+ centerTitle: true,
+ titleSpacing: 0.0,
+ title: Opacity(
+ opacity: _titleOpacity,
+ child: child,
+ ),
+ actions: actions,
+ expandedHeight: _expandedHeight,
+ flexibleSpace: FlexibleSpaceBar(
+ titlePadding: EdgeInsets.symmetric(
+ horizontal: _horizontalTitlePadding,
+ vertical: UIConstants.paddingMedium,
+ ),
+ title: Text(
+ title,
+ textScaleFactor: 1,
+ style: Theme.of(context).appBarTheme.titleTextStyle,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/core/presentation/widgets/buttons/custom_button.dart b/lib/core/presentation/widgets/buttons/custom_button.dart
new file mode 100644
index 0000000..8155e5f
--- /dev/null
+++ b/lib/core/presentation/widgets/buttons/custom_button.dart
@@ -0,0 +1,70 @@
+import 'package:flutter/material.dart';
+import 'package:piwigo_ng/core/extensions/build_context_extension.dart';
+import 'package:piwigo_ng/core/utils/app_text_styles.dart';
+import 'package:piwigo_ng/core/utils/constants/ui_constants.dart';
+
+class CustomButton extends StatelessWidget {
+ const CustomButton({
+ super.key,
+ this.onTap,
+ required this.text,
+ this.isLoading = false,
+ this.backgroundColor,
+ this.foregroundColor,
+ });
+
+ final void Function()? onTap;
+ final Color? backgroundColor;
+ final Color? foregroundColor;
+ final bool isLoading;
+ final String text;
+
+ @override
+ Widget build(BuildContext context) => ElevatedButton(
+ onPressed: onTap,
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(
+ horizontal: UIConstants.paddingMedium,
+ vertical: UIConstants.paddingXSmall,
+ ),
+ minimumSize: const Size.square(UIConstants.buttonHeight),
+ maximumSize: const Size.fromHeight(UIConstants.buttonHeight),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(UIConstants.radiusMedium),
+ ),
+ backgroundColor: backgroundColor ?? context.theme.colorScheme.secondary,
+ foregroundColor: foregroundColor ?? context.theme.colorScheme.primary,
+ ),
+ child: AnimatedSwitcher(
+ duration: UIConstants.animationDurationShort,
+ switchInCurve: Curves.ease,
+ switchOutCurve: Curves.ease,
+ transitionBuilder: (Widget child, Animation animation) {
+ return ScaleTransition(
+ scale: animation,
+ child: FadeTransition(
+ opacity: animation,
+ child: child,
+ ),
+ );
+ },
+ child: Builder(
+ key: ValueKey(isLoading),
+ builder: (BuildContext context) {
+ if (isLoading) {
+ return AspectRatio(
+ aspectRatio: 1,
+ child: CircularProgressIndicator(
+ color: foregroundColor ?? context.theme.colorScheme.primary,
+ ),
+ );
+ }
+ return Text(
+ text,
+ style: AppTextStyles.button,
+ );
+ },
+ ),
+ ),
+ );
+}
diff --git a/lib/core/presentation/widgets/buttons/custom_popup_menu_button.dart b/lib/core/presentation/widgets/buttons/custom_popup_menu_button.dart
new file mode 100644
index 0000000..313d047
--- /dev/null
+++ b/lib/core/presentation/widgets/buttons/custom_popup_menu_button.dart
@@ -0,0 +1,60 @@
+import 'package:flutter/material.dart';
+import 'package:piwigo_ng/core/extensions/build_context_extension.dart';
+import 'package:piwigo_ng/core/utils/constants/ui_constants.dart';
+
+class CustomPopupMenuButton extends StatelessWidget {
+ const CustomPopupMenuButton({
+ super.key,
+ required this.items,
+ });
+
+ final List items;
+
+ bool get _hasNotification => items.where((CustomPopupMenuItem item) => item.notification).isNotEmpty;
+
+ @override
+ Widget build(BuildContext context) => PopupMenuButton(
+ position: PopupMenuPosition.under,
+ icon: Badge(
+ isLabelVisible: _hasNotification,
+ child: const Icon(Icons.more_vert),
+ ),
+ itemBuilder: (BuildContext context) => items
+ .where((CustomPopupMenuItem items) => items.available)
+ .map>(
+ (CustomPopupMenuItem item) => PopupMenuItem(
+ onTap: item.onTap,
+ child: Row(
+ children: [
+ Badge(
+ isLabelVisible: item.notification,
+ child: Icon(item.icon),
+ ),
+ const SizedBox(width: UIConstants.paddingSmall),
+ Text(
+ item.label,
+ style: context.theme.textTheme.bodyMedium,
+ ),
+ ],
+ ),
+ ),
+ )
+ .toList(),
+ );
+}
+
+class CustomPopupMenuItem {
+ const CustomPopupMenuItem({
+ required this.label,
+ this.icon,
+ this.onTap,
+ this.available = true,
+ this.notification = false,
+ });
+
+ final Function()? onTap;
+ final String label;
+ final IconData? icon;
+ final bool available;
+ final bool notification; // todo: notification
+}
diff --git a/lib/core/presentation/widgets/buttons/custom_text_button.dart b/lib/core/presentation/widgets/buttons/custom_text_button.dart
new file mode 100644
index 0000000..aa31a6a
--- /dev/null
+++ b/lib/core/presentation/widgets/buttons/custom_text_button.dart
@@ -0,0 +1,36 @@
+import 'package:flutter/material.dart';
+import 'package:piwigo_ng/core/extensions/build_context_extension.dart';
+import 'package:piwigo_ng/core/utils/app_text_styles.dart';
+import 'package:piwigo_ng/core/utils/constants/ui_constants.dart';
+
+class CustomTextButton extends StatelessWidget {
+ const CustomTextButton({
+ super.key,
+ this.onTap,
+ required this.text,
+ this.foregroundColor,
+ });
+
+ final void Function()? onTap;
+ final Color? foregroundColor;
+ final String text;
+
+ @override
+ Widget build(BuildContext context) => Center(
+ child: TextButton(
+ style: TextButton.styleFrom(
+ foregroundColor: foregroundColor ?? context.theme.colorScheme.secondary,
+ padding: const EdgeInsets.symmetric(
+ horizontal: UIConstants.paddingXSmall,
+ vertical: UIConstants.paddingTiny,
+ ),
+ minimumSize: Size.zero,
+ ),
+ onPressed: onTap,
+ child: Text(
+ text,
+ style: AppTextStyles.textButton,
+ ),
+ ),
+ );
+}
diff --git a/lib/core/presentation/widgets/form/custom_text_field.dart b/lib/core/presentation/widgets/form/custom_text_field.dart
new file mode 100644
index 0000000..f696617
--- /dev/null
+++ b/lib/core/presentation/widgets/form/custom_text_field.dart
@@ -0,0 +1,184 @@
+import 'package:flutter/material.dart';
+import 'package:piwigo_ng/core/extensions/build_context_extension.dart';
+import 'package:piwigo_ng/core/utils/constants/ui_constants.dart';
+import 'package:piwigo_ng/core/utils/validators/field_validator.dart';
+
+class CustomTextField extends StatefulWidget {
+ const CustomTextField({
+ super.key,
+ this.controller,
+ this.prefix,
+ this.suffix,
+ this.focusNode,
+ this.textInputAction,
+ this.keyboardType,
+ this.hint,
+ this.label,
+ this.minLines,
+ this.maxLines = 1,
+ this.padding,
+ this.obscureText = false,
+ this.canErase = false,
+ this.color,
+ this.onFieldSubmitted,
+ this.onChanged,
+ this.autofillHints,
+ this.validators = const [],
+ });
+
+ final Widget? prefix;
+ final Widget? suffix;
+ final TextEditingController? controller;
+ final FocusNode? focusNode;
+ final TextInputAction? textInputAction;
+ final TextInputType? keyboardType;
+ final String? hint;
+ final String? label;
+ final int? minLines;
+ final int? maxLines;
+ final EdgeInsets? padding;
+ final bool obscureText;
+ final bool canErase;
+ final Color? color;
+ final Function(String)? onFieldSubmitted;
+ final Function(String)? onChanged;
+ final Iterable? autofillHints;
+ final List validators;
+
+ @override
+ State createState() => _CustomTextFieldState();
+}
+
+class _CustomTextFieldState extends State {
+ @override
+ void initState() {
+ widget.controller?.addListener(_listener);
+ super.initState();
+ }
+
+ @override
+ void dispose() {
+ widget.controller?.removeListener(_listener);
+ super.dispose();
+ }
+
+ void _listener() => setState(() {});
+
+ EdgeInsets get _contentPadding =>
+ widget.padding ??
+ const EdgeInsets.symmetric(
+ vertical: UIConstants.paddingMedium,
+ horizontal: UIConstants.paddingXSmall,
+ );
+
+ String? _validator(String? value) {
+ for (FieldValidator validator in widget.validators) {
+ String? error = validator.validate(context, value);
+ if (error != null) return error;
+ }
+ return null;
+ }
+
+ @override
+ Widget build(BuildContext context) => TextFormField(
+ controller: widget.controller,
+ focusNode: widget.focusNode,
+ onChanged: widget.onChanged,
+ onFieldSubmitted: widget.onFieldSubmitted,
+ textInputAction: widget.textInputAction,
+ keyboardType: widget.keyboardType,
+ obscureText: widget.obscureText,
+ minLines: widget.minLines,
+ maxLines: widget.maxLines,
+ autofillHints: widget.autofillHints,
+ style: Theme.of(context).textTheme.bodyMedium,
+ decoration: InputDecoration(
+ contentPadding: _contentPadding,
+ hintText: widget.hint,
+ labelText: widget.label,
+ alignLabelWithHint: true,
+ isDense: true,
+ prefixIconConstraints: const BoxConstraints(),
+ suffixIconConstraints: const BoxConstraints(),
+ prefixIcon: Padding(
+ padding: EdgeInsets.only(
+ left: UIConstants.paddingSmall,
+ right: _contentPadding.left,
+ ),
+ child: widget.prefix,
+ ),
+ suffixIcon: Padding(
+ padding: EdgeInsets.only(
+ right: UIConstants.paddingSmall,
+ left: _contentPadding.right,
+ ),
+ child: AnimatedSize(
+ duration: UIConstants.animationDurationShort,
+ curve: Curves.ease,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (widget.canErase && (widget.controller?.text.isNotEmpty ?? false)) _buildClearTextButton(),
+ if (widget.suffix != null) ...[
+ const SizedBox(width: UIConstants.paddingXSmall),
+ widget.suffix!,
+ ],
+ ],
+ ),
+ ),
+ ),
+ fillColor: widget.color ?? Theme.of(context).inputDecorationTheme.fillColor,
+ filled: true,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(UIConstants.radiusMedium),
+ borderSide: BorderSide.none,
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(UIConstants.radiusMedium),
+ borderSide: BorderSide.none,
+ ),
+ disabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(UIConstants.radiusMedium),
+ borderSide: BorderSide.none,
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(UIConstants.radiusMedium),
+ borderSide: BorderSide(
+ color: Theme.of(context).primaryColor,
+ width: UIConstants.thicknessMedium,
+ strokeAlign: BorderSide.strokeAlignOutside,
+ ),
+ ),
+ errorBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(UIConstants.radiusMedium),
+ borderSide: BorderSide(
+ color: Theme.of(context).colorScheme.error,
+ strokeAlign: BorderSide.strokeAlignOutside,
+ ),
+ ),
+ focusedErrorBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(UIConstants.radiusMedium),
+ borderSide: BorderSide(
+ color: Theme.of(context).colorScheme.error,
+ width: UIConstants.thicknessMedium,
+ strokeAlign: BorderSide.strokeAlignOutside,
+ ),
+ ),
+ errorStyle: TextStyle(
+ fontSize: 12,
+ fontStyle: FontStyle.italic,
+ color: context.theme.colorScheme.error,
+ ),
+ ),
+ validator: _validator,
+ );
+
+ Widget _buildClearTextButton() => GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => widget.controller?.clear(),
+ child: Icon(
+ Icons.clear,
+ color: Theme.of(context).colorScheme.secondary,
+ ),
+ );
+}
diff --git a/lib/core/presentation/widgets/images/custom_network_image.dart b/lib/core/presentation/widgets/images/custom_network_image.dart
new file mode 100644
index 0000000..748ef11
--- /dev/null
+++ b/lib/core/presentation/widgets/images/custom_network_image.dart
@@ -0,0 +1,151 @@
+import 'dart:io';
+
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:piwigo_ng/core/data/datasources/local/preferences_datasource.dart';
+import 'package:piwigo_ng/core/extensions/api_preferences_extension.dart';
+import 'package:piwigo_ng/network/api_client.dart';
+import 'package:piwigo_ng/services/preferences_service.dart';
+import 'package:piwigo_ng/utils/settings.dart';
+
+class CustomNetworkImage extends StatefulWidget {
+ const CustomNetworkImage({
+ super.key,
+ this.imageUrl,
+ this.fit,
+ });
+
+ final String? imageUrl;
+ final BoxFit? fit;
+
+ @override
+ State createState() => _CustomNetworkImageState();
+}
+
+class _CustomNetworkImageState extends State with AppPreferencesMixin {
+ late final Future