From 4146f8ba49d4b47b50b494cfd4d5cc14a0dcc84a Mon Sep 17 00:00:00 2001 From: Kevin McDermott-Carpenter Date: Fri, 25 Oct 2024 22:02:40 +0100 Subject: [PATCH 1/9] feat: new password validators --- lib/core/utils/validators.dart | 17 ++++++++++++++--- lib/l10n/app_en.arb | 13 +++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart index 3e66610..1bccc47 100644 --- a/lib/core/utils/validators.dart +++ b/lib/core/utils/validators.dart @@ -30,11 +30,9 @@ class Validators { [ RequiredValidator(errorText: AppLocale.instance.current.required), MinLengthValidator( - 6, + 8, errorText: AppLocale.instance.current.invalidPasswordLengthError, ), - PatternValidator(r'(?=.*?[#?!@$%^&*-=])', - errorText: AppLocale.instance.current.invalidPasswordError), ], ).call; @@ -46,6 +44,19 @@ class Validators { static final optionalEmail = EmailValidator(errorText: AppLocale.instance.current.invalidEmailError); + static String? newPassword(String current, String? replacement) { + if (replacement == null) { + return AppLocale.instance.current.required; + } + if (replacement.length < 8) { + return AppLocale.instance.current.invalidPasswordLengthError; + } + if (current == replacement) { + return AppLocale.instance.current.passwordSameAsOldError; + } + return null; + } + static String? passwordMatch( {required String? value1, required String? value2}) { if (value1 != value2) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2bbaf6e..e215d38 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -26,23 +26,24 @@ "@invalidValueError": { "description": "The user passed an invalid value into an integer text field so it could not be parsed" }, - "invalidPasswordLengthError": "Password must be at least 6 digits", + "invalidPasswordLengthError": "Invalid password", "@invalidPasswordLengthError": { - "description": "The user entered a password that was less than 6 digits long" - }, - "invalidPasswordError": "Passwords must have at least one special character", - "@invalidPasswordError": { - "description": "The user entered a password without any special characters." + "description": "The user entered a password that was less than 8 digits long" }, "invalidPasswordMatchError": "Passwords must match", "@invalidPasswordMatchError": { "description": "The user entered two passwords that were not the same" }, + "passwordSameAsOldError" : "New password cannot be the same as the old password", + "@passwordSameAsOldError": { + "description": "The user tried to use the same password on the change password form" + }, "invalidEmailError": "Please enter a valid email", "serverUrl": "Server url", "next": "Next", "email": "Email", "password": "Password", + "newPassword": "New password", "back": "Back", "login": "login", "buildingLibrary": "Please wait. Building library", From 2adc5852e3ee8ec50c14ff7440ec9723d8130eed Mon Sep 17 00:00:00 2001 From: Kevin McDermott-Carpenter Date: Fri, 25 Oct 2024 22:03:18 +0100 Subject: [PATCH 2/9] fix: user object differs across endpoints --- lib/models/user.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/models/user.dart b/lib/models/user.dart index a93c458..3f9b084 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -29,8 +29,8 @@ class User { factory User.fromJson(JsonMap json, [String? token]) { return User( token: token ?? json['accessToken'], - id: json['userId'], - email: json['userEmail'], + id: json['userId'] ?? json['id'], + email: json['userEmail'] ?? json['email'], name: json['name'], shouldChangePassword: json['shouldChangePassword'], ); @@ -38,7 +38,7 @@ class User { @override int get hashCode => id.hashCode; - + @override bool operator ==(Object other) { return other is User && other.id == id; From c8ff5d383315bd7ccd814b1b787372c7fe5c6ef2 Mon Sep 17 00:00:00 2001 From: Kevin McDermott-Carpenter Date: Fri, 25 Oct 2024 22:03:35 +0100 Subject: [PATCH 3/9] feat: add password change route --- lib/routes/app_router.dart | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/routes/app_router.dart b/lib/routes/app_router.dart index 8c7ce37..d41a8d7 100644 --- a/lib/routes/app_router.dart +++ b/lib/routes/app_router.dart @@ -15,6 +15,7 @@ const _kLoginRoute = '/login'; const _kPreferencesRoute = 'preferences'; const _kAssetViewerRoute = 'assets'; const _kAlbumRote = 'album'; +const _kChangePasswordRoute = 'reset-password'; class AppRouter { AppRouter(this._auth); @@ -26,9 +27,19 @@ class AppRouter { GlobalKey get navigatorKey => _navigatorKey; - Future _authRedirect(bool authRequired) async { + Future _authRedirect( + bool authRequired, [ + bool isPasswordRoute = false, + ]) async { final authenticated = await _auth.checkAuthStatus(); + if (authenticated && !isPasswordRoute) { + final user = await _auth.currentUser(); + if (user!.shouldChangePassword) { + return '/$_kChangePasswordRoute'; + } + } + if (authenticated && !authRequired) { return _kLibraryRoute; } @@ -59,6 +70,13 @@ class AppRouter { builder: (_, __) => const PreferencesScreen(), ); + final _changePasswordRoute = GoRoute( + path: _kChangePasswordRoute, + builder: (_, __) => const AuthScreen( + passwordChange: true, + ), + ); + late final _albumRoute = GoRoute( path: _kAlbumRote, builder: (_, state) => AlbumScreen(album: state.extra as Album), @@ -73,6 +91,7 @@ class AppRouter { _assetViewerRoute, _preferencesRoute, _albumRoute, + _changePasswordRoute, ], ), GoRoute( @@ -96,8 +115,7 @@ class AppRouter { static void toNotificationAssetViewer(AssetViewerScreenState viewerState) => to(_kAssetViewerRoute, null, viewerState); - static void toLibrary(BuildContext context) => - GoRouter.of(context).go(_kLibraryRoute); + static void toLibrary(BuildContext context) => to(_kLibraryRoute, context); static void toPreferences(BuildContext context) => to(_kPreferencesRoute, context); static void toLogin(BuildContext context) => to(_kLoginRoute, context); @@ -108,4 +126,6 @@ class AppRouter { AssetViewerScreenState viewerState, ) => to(_kAssetViewerRoute, context, viewerState); + static void toChangePassword(BuildContext context) => + to(_kChangePasswordRoute, context); } From 7c6aa1abb1a29e7a839f34b0c173a07225c0ebf2 Mon Sep 17 00:00:00 2001 From: Kevin McDermott-Carpenter Date: Fri, 25 Oct 2024 23:15:24 +0100 Subject: [PATCH 4/9] feat: password change screen --- lib/screens/auth/auth_screen.dart | 61 ++++-- lib/screens/auth/change_password_widget.dart | 183 ++++++++++++++++++ lib/screens/auth/login_widget.dart | 5 +- .../preferences/user/password_widget.dart | 5 +- lib/services/api/api_service.dart | 125 ++++++++---- lib/services/auth/auth_service.dart | 13 +- 6 files changed, 330 insertions(+), 62 deletions(-) create mode 100644 lib/screens/auth/change_password_widget.dart diff --git a/lib/screens/auth/auth_screen.dart b/lib/screens/auth/auth_screen.dart index 4b38a50..b76aa7b 100644 --- a/lib/screens/auth/auth_screen.dart +++ b/lib/screens/auth/auth_screen.dart @@ -3,14 +3,20 @@ import 'package:flutter/material.dart'; import '../../core/components/logo_widget.dart'; import '../../core/components/scaffold/app_scaffold.dart'; import '../../routes/app_router.dart'; +import 'change_password_widget.dart'; import 'endpoint_widget.dart'; import 'login_widget.dart'; class AuthScreen extends StatelessWidget { - const AuthScreen({super.key}); + const AuthScreen({ + this.passwordChange = false, + super.key, + }); static const id = 'auth_screen'; + final bool passwordChange; + @override Widget build(BuildContext context) { return AppScaffold( @@ -19,16 +25,16 @@ class AuthScreen extends StatelessWidget { body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 300), - child: const Card( + child: Card( child: SingleChildScrollView( - padding: EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - LogoImageText(), - SizedBox(height: 10), - AuthScreenContent(), + const LogoImageText(), + const SizedBox(height: 10), + AuthScreenContent(passwordChange: passwordChange), ], ), ), @@ -40,14 +46,28 @@ class AuthScreen extends StatelessWidget { } class AuthScreenContent extends StatefulWidget { - const AuthScreenContent({super.key}); + const AuthScreenContent({ + required this.passwordChange, + super.key, + }); + + final bool passwordChange; @override State createState() => _AuthScreenContentState(); } class _AuthScreenContentState extends State { - _State _state = _State.endpoint; + late _State _state = + widget.passwordChange ? _State.changePassword : _State.endpoint; + + void _updateState(_State newState) { + if (mounted) { + setState(() { + _state = newState; + }); + } + } @override Widget build(BuildContext context) { @@ -56,19 +76,27 @@ class _AuthScreenContentState extends State { child: switch (_state) { _State.endpoint => EndpointWidget( onEndpointSaved: (isOAuth) { - setState(() { - _state = isOAuth ? _State.oauth : _State.login; - }); + _updateState(_state = isOAuth ? _State.oauth : _State.login); + }, + ), + _State.changePassword => ChangePasswordWidget( + onLogout: () { + _updateState(_State.endpoint); + }, + onComplete: () { + AppRouter.toLibrary(context); }, ), _State _ => LoginWidget( onBack: () { - setState(() { - _state = _State.endpoint; - }); + _updateState(_State.endpoint); }, - onLoginComplete: () { - AppRouter.toLibrary(context); + onLoginComplete: (user) { + if (user.shouldChangePassword) { + _updateState(_State.changePassword); + } else { + AppRouter.toLibrary(context); + } }, ), }, @@ -80,4 +108,5 @@ enum _State { endpoint, login, oauth, + changePassword, } diff --git a/lib/screens/auth/change_password_widget.dart b/lib/screens/auth/change_password_widget.dart new file mode 100644 index 0000000..52fb14e --- /dev/null +++ b/lib/screens/auth/change_password_widget.dart @@ -0,0 +1,183 @@ +import 'package:album_share/routes/app_router.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/components/app_snackbar.dart'; +import '../../core/utils/app_localisations.dart'; +import '../../core/utils/validators.dart'; +import '../../services/api/api_service.dart'; +import '../../services/auth/auth_providers.dart'; + +class ChangePasswordWidget extends ConsumerStatefulWidget { + const ChangePasswordWidget({ + required this.onLogout, + required this.onComplete, + super.key, + }); + + /// Called if the user cannot reset password (different user?) + /// + /// When this is called, the user has already been logged out. + final VoidCallback onLogout; + final VoidCallback onComplete; + + @override + ConsumerState createState() => _LoginWidgetState(); +} + +class _LoginWidgetState extends ConsumerState { + final _formKey = GlobalKey(); + + late final bool _mustChangePassword; + + bool _loading = false; + + String _password = ''; + String _newPassword = ''; + + @override + void initState() { + super.initState(); + _mustChangePassword = + ref.read(AuthProviders.userStream).value?.shouldChangePassword ?? false; + } + + void _onBack() { + if (_mustChangePassword) { + _logout(); + } else { + AppRouter.back(context); + } + } + + void _logout() async { + _setLoading(true); + + await ref + .read(AuthProviders.service) // + .logout() + .then((bool loggedOut) => // + loggedOut // + ? widget.onLogout() + : _onError('Failed to logout')) + .onError((ApiException e, _) => // + _onError(e.message)); + } + + void _submit() async { + final form = _formKey.currentState!; + if (!form.validate()) { + return; + } + + _setLoading(true); + + form.save(); + + await ref + .read(AuthProviders.service) + .changePassword(_newPassword) + .then((_) => widget.onComplete()) + .onError((ApiException e, _) => _onError(e.message)); + } + + void _onError(String message) { + if (mounted) { + setState(() { + _loading = false; + }); + AppSnackbar.warning(context: context, message: message); + } + } + + void _setLoading(bool loading) { + if (mounted) { + setState(() { + _loading = loading; + }); + } + } + + @override + Widget build(BuildContext context) { + final locale = AppLocalizations.of(context)!; + return AutofillGroup( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(locale.changePassword), + const SizedBox(height: 10), + TextFormField( + obscureText: true, + validator: Validators.password, + onSaved: (v) => _password = v!, + onChanged: (v) => _password = v, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.password], + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: locale.password, + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + const SizedBox(height: 10), + TextFormField( + obscureText: true, + validator: (v) => Validators.newPassword(_password, v), + onChanged: (v) => _newPassword = v, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.newPassword], + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: locale.newPassword, + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + const SizedBox(height: 10), + TextFormField( + obscureText: true, + validator: (value) => Validators.passwordMatch( + value1: value, + value2: _newPassword, + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + autofillHints: const [AutofillHints.newPassword], + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: locale.newPassword, + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + if (_loading) + const Padding( + padding: EdgeInsets.only(top: 5.0), + child: LinearProgressIndicator(), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + onPressed: _loading ? null : _onBack, + child: + Text(_mustChangePassword ? locale.signOut : locale.back), + ), + FilledButton( + iconAlignment: IconAlignment.end, + onPressed: _loading ? null : _submit, + child: Text( + locale.save, + style: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/auth/login_widget.dart b/lib/screens/auth/login_widget.dart index 68fe557..4ab4d5b 100644 --- a/lib/screens/auth/login_widget.dart +++ b/lib/screens/auth/login_widget.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/components/app_snackbar.dart'; import '../../core/utils/app_localisations.dart'; import '../../core/utils/validators.dart'; +import '../../models/user.dart'; import '../../services/api/api_service.dart'; import '../../services/auth/auth_providers.dart'; @@ -15,7 +16,7 @@ class LoginWidget extends ConsumerStatefulWidget { }); final VoidCallback onBack; - final VoidCallback onLoginComplete; + final void Function(User user) onLoginComplete; @override ConsumerState createState() => _LoginWidgetState(); @@ -42,7 +43,7 @@ class _LoginWidgetState extends ConsumerState { await ref .read(AuthProviders.service) .login(_email, _password) - .then((_) => widget.onLoginComplete()) + .then(widget.onLoginComplete) .onError((ApiException e, _) => _onError(e.message)); } diff --git a/lib/screens/preferences/user/password_widget.dart b/lib/screens/preferences/user/password_widget.dart index 046544d..3e82dea 100644 --- a/lib/screens/preferences/user/password_widget.dart +++ b/lib/screens/preferences/user/password_widget.dart @@ -1,3 +1,4 @@ +import 'package:album_share/routes/app_router.dart'; import 'package:flutter/material.dart'; import '../../../core/utils/app_localisations.dart'; @@ -10,10 +11,10 @@ class PasswordWidget extends StatelessWidget { return ListTile( title: Text(AppLocalizations.of(context)!.changePassword), trailing: IconButton( - onPressed: () {}, + onPressed: () => AppRouter.toChangePassword(context), icon: const Icon(Icons.chevron_right), ), - onTap: () {}, + onTap: () => AppRouter.toChangePassword(context), ); } } diff --git a/lib/services/api/api_service.dart b/lib/services/api/api_service.dart index 0c83016..6fc594c 100644 --- a/lib/services/api/api_service.dart +++ b/lib/services/api/api_service.dart @@ -161,7 +161,7 @@ class ApiService { return body['authStatus']; } on DioException catch (e, s) { _logger.severe('Unable to validate endpoint token', e, s); - throw ApiException.fromDioException(e); + throw ApiException.fromDioException(e, s); } } @@ -176,14 +176,42 @@ class ApiService { expected: JSON_MAP, ); - // Access token is not returned in the response. - // However, because the user is authenticated the access token can be found in the request cookies. + return User.fromJson(body, await _currentUserToken()); + } + + /// Changes the password for the current authenticated user. + /// + /// Throws[ApiException] if unsuccessful + Future changePassword(String password) async { + _expectEndpointSet(); + + // The change password endpoint doesn't update shouldChangePassword + final body = await _put( + '/api/users/me', + expected: JSON_MAP, + data: { + 'password': password, + }, + headers: { + 'Accept': _applicationJson, + 'Content-Type': _applicationJson, + }, + ); + + return User.fromJson(body, await _currentUserToken()); + } + + /// Access token is not always returned in the user object + /// + /// However, if the user is authenticated, the access token can be found in + /// the request cookies. + Future _currentUserToken() async { final cookies = await _cookieJar.loadForRequest(Uri.parse(_dio.options.baseUrl)); - final token = - cookies.firstWhere((e) => e.name == 'immich_access_token').value; - return User.fromJson(body, token); + return cookies + .firstWhere((cookie) => cookie.name == 'immich_access_token') + .value; } /// Logs out the current user. @@ -202,25 +230,6 @@ class ApiService { return body['successful'] as bool; } - /// Changes the password of the current user. - /// - /// Throws [ApiException] if unsuccessful. - Future changePassword(String password, String newPassword) async { - _expectEndpointSet(); - - await _post( - '/api/auth/change-password', - data: { - "password": password, - "newPassword": newPassword, - }, - headers: { - 'Content-Type': _applicationJson, - }, - expected: JSON_MAP, - ); - } - /// Retrieves a list of all albums shared with this user. /// /// Throws [ApiException] if unsuccessful. @@ -428,8 +437,7 @@ class ApiService { ? await _extractObjectFromResponse(response) as T : await _extractObjectListFromResponse(response) as T; } on DioException catch (e, s) { - _logger.severe('Unexpected DioException', e, s); - throw ApiException.fromDioException(e); + throw ApiException.fromDioException(e, s); } } @@ -457,25 +465,70 @@ class ApiService { ? await _extractObjectFromResponse(response) as T : await _extractObjectListFromResponse(response) as T; } on DioException catch (e, s) { - // Unable to reach endpoint. - _logger.severe('Unexpected DioException', e, s); + throw ApiException.fromDioException(e, s); + } + } + + /// Sends a put request with the supplied data. + /// + /// Returns the response data as a [String] + /// + /// Throws [ApiException] if unsuccessful. + Future _put( + String endpoint, { + JsonMap? data, + Map? headers, + required T expected, + }) async { + assert(expected is JsonMap || expected is List); + + try { + final response = await _dio.put( + endpoint, + data: data, + options: Options(headers: headers), + ); - throw ApiException.fromDioException(e); + return expected is JsonMap? + ? await _extractObjectFromResponse(response) as T + : await _extractObjectListFromResponse(response) as T; + } on DioException catch (e, s) { + throw ApiException.fromDioException(e, s); } } } class ApiException implements Exception { - const ApiException(this.type, this.debugMessage); + const ApiException(this.type, this.message); + + // ApiException.fromDioException(DioException e) + // : type = ApiExceptionType.fromDioException(e), + // debugMessage = e.message ?? 'An unknown error occurred.'; + factory ApiException.fromDioException(DioException e, StackTrace s) { + String? error; + if (e.response?.data is JsonMap && + (e.response!.data as JsonMap).containsKey('message')) { + error = e.response!.data['message']; + } + + _logger.severe( + 'API request failed.', + error == null ? e : e.response!.data, + s, + ); - ApiException.fromDioException(DioException e) - : type = ApiExceptionType.fromDioException(e), - debugMessage = e.message ?? 'An unknown error occurred.'; + final type = ApiExceptionType.fromDioException(e); + + return ApiException( + type, + error ?? _messageFromType(type), + ); + } + final String message; final ApiExceptionType type; - final String debugMessage; - String get message { + static String _messageFromType(ApiExceptionType type) { return switch (type) { ApiExceptionType.client => 'There was an error processing the request.', ApiExceptionType.server => 'There was an error processing the response.', diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart index 7e71e63..78d2cea 100644 --- a/lib/services/auth/auth_service.dart +++ b/lib/services/auth/auth_service.dart @@ -114,12 +114,6 @@ class AuthService { /// Returns null if not authenticated. /// Gets the currently signed in user, or null if not authenticated. Future currentUser() async { - // If subscription has been subscribed to, - // the data will have already been fetched. - if (_currentUserStream.hasListener) { - return userChanges().first; - } - final authenticated = await checkAuthStatus(); if (!authenticated) { @@ -138,6 +132,13 @@ class AuthService { return user; } + Future changePassword(String password) async { + final user = await _api.changePassword(password); + + await _db.setUser(user); + _currentUserStream.add(user); + } + /// A stream of events for the current user. /// /// Null if not authenticated. From 9f14376817d920d7fb51f8279c89c1010d2ec59a Mon Sep 17 00:00:00 2001 From: Kevin McDermott-Carpenter Date: Sat, 26 Oct 2024 00:02:18 +0100 Subject: [PATCH 5/9] feat: follow system accent theme --- lib/core/theme/app_theme.dart | 33 +++++++++---------- lib/main.dart | 3 ++ linux/flutter/generated_plugin_registrant.cc | 4 +++ linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ macos/Podfile.lock | 6 ++++ pubspec.lock | 16 +++++++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 ++ windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 52 insertions(+), 18 deletions(-) diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index cafe245..c93050a 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,41 +1,38 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:system_theme/system_theme.dart'; class AppTheme { - static ThemeData light() { - final base = ThemeData.from( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.purple, - brightness: Brightness.light, - ), - ); + static Future ensureInitialized() async { + SystemTheme.fallbackColor = Colors.purple; + await SystemTheme.accentColor.load(); + } - return base.copyWith( - appBarTheme: base.appBarTheme.copyWith( - systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( - statusBarColor: Colors.transparent, - ), - ), - ); + static Color systemThemeColour() { + return SystemTheme.accentColor.accent; } - static ThemeData dark() { + static ThemeData base(Brightness brightness) { final base = ThemeData.from( colorScheme: ColorScheme.fromSeed( - seedColor: Colors.purple, - brightness: Brightness.dark, + seedColor: systemThemeColour(), + brightness: brightness, ), ); return base.copyWith( appBarTheme: base.appBarTheme.copyWith( - systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( statusBarColor: Colors.transparent, ), ), ); } + static ThemeData light() => base(Brightness.light); + + static ThemeData dark() => base(Brightness.dark); + ThemeData themeFromBrightness(Brightness brightness) { return switch (brightness) { Brightness.light => light(), diff --git a/lib/main.dart b/lib/main.dart index bf9ec11..4fdd8fb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,11 +7,14 @@ import 'core/components/app_window/app_window.dart'; import 'core/main/app_lifecycle_scope.dart'; import 'core/main/locale_scope.dart'; import 'core/main/main_app.dart'; +import 'core/theme/app_theme.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); + AppTheme.ensureInitialized(); VideoPlayer.ensureInitialized(); AppWindow.ensureInitialized(); + runApp( const ProviderScope( child: LocaleScope( diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 443c24c..1b12179 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -26,6 +27,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) screen_retriever_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); + g_autoptr(FlPluginRegistrar) system_theme_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); + system_theme_plugin_register_with_registrar(system_theme_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 6003c55..d7f8669 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_linux media_kit_video screen_retriever + system_theme url_launcher_linux window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3dfda88..23db9c4 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,6 +14,7 @@ import path_provider_foundation import screen_brightness_macos import screen_retriever import sqflite +import system_theme import url_launcher_macos import wakelock_plus import window_manager @@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index f694fd5..d514a25 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -22,6 +22,8 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS + - system_theme (0.0.1): + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS - wakelock_plus (0.0.1): @@ -41,6 +43,7 @@ DEPENDENCIES: - screen_brightness_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) @@ -68,6 +71,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos sqflite: :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + system_theme: + :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos wakelock_plus: @@ -87,6 +92,7 @@ SPEC CHECKSUMS: screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 diff --git a/pubspec.lock b/pubspec.lock index a5cc4f1..9e8c118 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1191,6 +1191,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0+2" + system_theme: + dependency: "direct main" + description: + name: system_theme + sha256: "5f93485401689601d4636a695f99f7c70a30873ee68c1d95025d908a3386be7e" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + system_theme_web: + dependency: transitive + description: + name: system_theme_web + sha256: "900c92c5c050ce58048f241ef9a17e5cd8629808325a05b473dc62a6e99bae77" + url: "https://pub.dev" + source: hosted + version: "0.0.3" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6282bae..c04f685 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: background_fetch: ^1.3.7 url_launcher: ^6.3.0 flutter_local_notifications: ^17.2.3 + system_theme: ^3.1.2 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 7529bb7..8e3928d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -25,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin")); ScreenRetrieverPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); + SystemThemePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SystemThemePlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4d84323..ce29abd 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_brightness_windows screen_retriever + system_theme url_launcher_windows window_manager ) From 0470bda97027fd891327930d2226cb4d8d043f6f Mon Sep 17 00:00:00 2001 From: Kevin McDermott-Carpenter Date: Sat, 26 Oct 2024 21:45:59 +0100 Subject: [PATCH 6/9] fix: change password requires change password and update user methods --- lib/screens/auth/change_password_widget.dart | 4 +- lib/services/api/api_service.dart | 44 +++++++++++++++++--- lib/services/auth/auth_service.dart | 13 ++++-- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/lib/screens/auth/change_password_widget.dart b/lib/screens/auth/change_password_widget.dart index 52fb14e..d6c00ce 100644 --- a/lib/screens/auth/change_password_widget.dart +++ b/lib/screens/auth/change_password_widget.dart @@ -1,10 +1,10 @@ -import 'package:album_share/routes/app_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/components/app_snackbar.dart'; import '../../core/utils/app_localisations.dart'; import '../../core/utils/validators.dart'; +import '../../routes/app_router.dart'; import '../../services/api/api_service.dart'; import '../../services/auth/auth_providers.dart'; @@ -76,7 +76,7 @@ class _LoginWidgetState extends ConsumerState { await ref .read(AuthProviders.service) - .changePassword(_newPassword) + .changePassword(_password, _newPassword) .then((_) => widget.onComplete()) .onError((ApiException e, _) => _onError(e.message)); } diff --git a/lib/services/api/api_service.dart b/lib/services/api/api_service.dart index 6fc594c..5730cb5 100644 --- a/lib/services/api/api_service.dart +++ b/lib/services/api/api_service.dart @@ -2,14 +2,14 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:album_share/core/utils/extension_methods.dart'; -import 'package:album_share/models/activity.dart'; 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:logging/logging.dart'; +import '../../core/utils/extension_methods.dart'; +import '../../models/activity.dart'; import '../../models/album.dart'; import '../../models/asset.dart'; import '../../models/endpoint.dart'; @@ -179,18 +179,26 @@ class ApiService { return User.fromJson(body, await _currentUserToken()); } - /// Changes the password for the current authenticated user. + /// Updates the name, email or password of the currently authenticated user. /// /// Throws[ApiException] if unsuccessful - Future changePassword(String password) async { + Future updateUser({ + String? name, + String? email, + String? password, + }) async { _expectEndpointSet(); + assert(name != null || email != null || password != null); + // The change password endpoint doesn't update shouldChangePassword final body = await _put( '/api/users/me', expected: JSON_MAP, data: { - 'password': password, + if (name != null) 'name': name, + if (email != null) 'email': email, + if (password != null) 'password': password, }, headers: { 'Accept': _applicationJson, @@ -201,6 +209,32 @@ class ApiService { return User.fromJson(body, await _currentUserToken()); } + /// Changes the password for the current authenticated user. + /// + /// Use in combination with [updateUser] as [User.shouldChangePassword] is + /// not updated when calling [changePassword]. + /// + /// Throws[ApiException] if unsuccessful + Future changePassword(String password, String newPassword) async { + _expectEndpointSet(); + + // The change password endpoint doesn't update shouldChangePassword + final body = await _post( + '/api/auth/change-password', + expected: JSON_MAP, + data: { + 'password': password, + 'newPassword': newPassword, + }, + headers: { + 'Accept': _applicationJson, + 'Content-Type': _applicationJson, + }, + ); + + return body.containsKey('id'); + } + /// Access token is not always returned in the user object /// /// However, if the user is authenticated, the access token can be found in diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart index 78d2cea..fd56893 100644 --- a/lib/services/auth/auth_service.dart +++ b/lib/services/auth/auth_service.dart @@ -132,11 +132,16 @@ class AuthService { return user; } - Future changePassword(String password) async { - final user = await _api.changePassword(password); + Future changePassword(String password, String newPassword) async { + final passwordChanged = await _api.changePassword(password, newPassword); - await _db.setUser(user); - _currentUserStream.add(user); + if (passwordChanged) { + final user = await _api.updateUser(password: newPassword); + await _db.setUser(user); + _currentUserStream.add(user); + } + + return passwordChanged; } /// A stream of events for the current user. From e8eff0884741d8f736a7c3fc6cbde607aced6783 Mon Sep 17 00:00:00 2001 From: Kevin McDermott-Carpenter Date: Sat, 26 Oct 2024 22:17:11 +0100 Subject: [PATCH 7/9] fix: sidebar difficult to see in light mode --- lib/core/components/sidebar/activity_sidebar.dart | 2 +- lib/core/components/sidebar/notifications_sidebar.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/components/sidebar/activity_sidebar.dart b/lib/core/components/sidebar/activity_sidebar.dart index 2a3c85e..48bdfda 100644 --- a/lib/core/components/sidebar/activity_sidebar.dart +++ b/lib/core/components/sidebar/activity_sidebar.dart @@ -28,7 +28,7 @@ class ActivitySidebar extends AppSidebar { return Drawer( width: AppSidebar.width, backgroundColor: - Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5), + Theme.of(context).scaffoldBackgroundColor.withOpacity(0.9), child: SafeArea( top: false, child: Consumer( diff --git a/lib/core/components/sidebar/notifications_sidebar.dart b/lib/core/components/sidebar/notifications_sidebar.dart index 1ecba10..52b854a 100644 --- a/lib/core/components/sidebar/notifications_sidebar.dart +++ b/lib/core/components/sidebar/notifications_sidebar.dart @@ -27,7 +27,7 @@ class NotificationSidebar extends AppSidebar { final provider = ref.watch(ActivityProviders.notifications); final theme = Theme.of(context); return Drawer( - backgroundColor: theme.scaffoldBackgroundColor.withOpacity(0.5), + backgroundColor: theme.scaffoldBackgroundColor.withOpacity(0.9), width: AppSidebar.width, child: provider.when( data: (a) { From babaefc3cf85d85a9a5d29b6c226bccea85814df Mon Sep 17 00:00:00 2001 From: Kevin McDermott-Carpenter Date: Sat, 26 Oct 2024 22:31:03 +0100 Subject: [PATCH 8/9] chore: bump version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index c04f685..4ff3781 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: album_share description: "An unofficial Immich client focused on album sharing." publish_to: 'none' -version: 0.3.2 +version: 0.3.3 environment: sdk: '>=3.3.1 <4.0.0' From 5d5eec11ed165df632c3be7b86e2d8a23c019d46 Mon Sep 17 00:00:00 2001 From: Kevin McDermott-Carpenter Date: Sat, 26 Oct 2024 22:31:11 +0100 Subject: [PATCH 9/9] chore: update changelog --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22caefc..4deb831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.3] - 25/10/2024 + +### Added + +- Password changes +After logging in, users are prompted to update their password if shouldChangePassword is true. +Users can also change their password if they wish by navigating to the settings screen. + +- Dynamic theming. +Album share now uses the system color scheme when available. + +### Changed + +- Activity and notification sidebars have some transparency removed, this makes content much easier to see. ## [0.3.2] - 21/10/2024 @@ -61,3 +75,5 @@ Now, when the user attempts to navigate back, the image scale is first reset the [0.2.1]: https://github.com/ConcenTech/album_share/compare/main...0.2.1 [0.3.0]: https://github.com/ConcenTech/album_share/compare/0.2.1...0.3.0 +[0.3.2]: https://github.com/ConcenTech/album_share/compare/0.3.0...0.3.2 +[0.3.3]: https://github.com/ConcenTech/album_share/compare/0.3.2...0.3.3 \ No newline at end of file