add adaptive_scaffold

This commit is contained in:
shenlong-tanwen
2024-05-24 09:42:02 +05:30
parent fb6253d2d1
commit 1631df70e9
295 changed files with 2540 additions and 44480 deletions
@@ -0,0 +1,27 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/domain/models/server-info/server_feature_config.model.dart';
import 'package:immich_mobile/domain/services/server_info.service.dart';
class ServerFeatureConfigCubit extends Cubit<ServerFeatureConfig> {
final ServerInfoService _serverInfoService;
ServerFeatureConfigCubit(this._serverInfoService)
: super(const ServerFeatureConfig.reset());
Future<void> getFeatures() async =>
await Future.wait([_getFeatures(), _getConfig()]);
Future<void> _getFeatures() async {
final features = await _serverInfoService.getServerFeatures();
if (features != null) {
emit(state.copyWith(features: features));
}
}
Future<void> _getConfig() async {
final config = await _serverInfoService.getServerConfig();
if (config != null) {
emit(state.copyWith(config: config));
}
}
}
@@ -1,5 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/router/router.dart';
@RoutePage()
class HomePage extends StatelessWidget {
@@ -7,6 +8,11 @@ class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container();
return Center(
child: ElevatedButton(
onPressed: () => context.router.navigate(const SettingsRoute()),
child: const Text('Settings'),
),
);
}
}
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
@immutable
class LoginPageState {
final bool isServerValidated;
final bool isValidationInProgress;
const LoginPageState({
required this.isServerValidated,
required this.isValidationInProgress,
});
factory LoginPageState.reset() {
return const LoginPageState(
isServerValidated: false,
isValidationInProgress: false,
);
}
LoginPageState copyWith({
bool? isServerValidated,
bool? isValidationInProgress,
}) {
return LoginPageState(
isServerValidated: isServerValidated ?? this.isServerValidated,
isValidationInProgress:
isValidationInProgress ?? this.isValidationInProgress,
);
}
@override
String toString() =>
'LoginPageState(isServerValidated: $isServerValidated, isValidationInProgress: $isValidationInProgress)';
@override
bool operator ==(covariant LoginPageState other) {
if (identical(this, other)) return true;
return other.isServerValidated == isServerValidated &&
other.isValidationInProgress == isValidationInProgress;
}
@override
int get hashCode =>
isServerValidated.hashCode ^ isValidationInProgress.hashCode;
}
@@ -0,0 +1,187 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/presentation/components/common/gap.widget.dart';
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_scaffold_body.widget.dart';
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
import 'package:immich_mobile/presentation/modules/login/widgets/login_form.widget.dart';
import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/utils/constants/size_constants.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@RoutePage()
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
final TextEditingController _serverUrlController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(seconds: 60),
vsync: this,
)..repeat();
}
@override
void dispose() {
_animationController.dispose();
_serverUrlController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _populateDemoCredentials() {
_serverUrlController.text = 'https://demo.immich.app';
_emailController.text = 'demo@immich.app';
_passwordController.text = 'demo';
}
@override
Widget build(BuildContext context) {
final PreferredSizeWidget? appBar;
late final Widget primaryBody;
late final Widget secondaryBody;
Widget rotatingLogo = GestureDetector(
onDoubleTap: _populateDemoCredentials,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RotationTransition(
turns: _animationController,
child: const ImLogo(width: 100),
),
const SizedGap.lh(),
const ImLogoText(),
],
),
),
);
final Widget form = FractionallySizedBox(
widthFactor: 0.8,
child: LoginForm(
serverUrlController: _serverUrlController,
emailController: _emailController,
passwordController: _passwordController,
),
);
final Widget bottom = Padding(
padding: const EdgeInsets.only(bottom: SizeConstants.s),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FutureBuilder(
future: PackageInfo.fromPlatform(),
builder: (_, snap) => DefaultTextStyle.merge(
style: TextStyle(color: context.theme.colorScheme.outline),
child: Text(snap.data?.version ?? ''),
),
),
TextButton(
onPressed: () => context.navigateRoot(const LogsRoute()),
child: const Text('Logs'),
),
],
),
);
final serverUrl = BlocSelector<LoginPageCubit, LoginPageState, bool>(
selector: (state) => state.isServerValidated,
builder: (_, isValidated) => isValidated
? Padding(
padding: const EdgeInsets.only(bottom: SizeConstants.m),
child: DefaultTextStyle.merge(
style: TextStyle(
color: context.theme.primaryColor,
fontWeight: FontWeight.w500,
),
child: InkWell(
onTap: () => launchUrl(Uri.parse(_serverUrlController.text)),
child: Text(
_serverUrlController.text,
textAlign: TextAlign.center,
),
),
),
)
: const SizedBox.shrink(),
);
const PreferredSizeWidget topBar = _MobileAppBar();
if (context.isTablet) {
appBar = null;
primaryBody = Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [rotatingLogo, const SizedGap.mh(), serverUrl],
),
);
secondaryBody = Column(
children: [topBar, Expanded(child: Center(child: form)), bottom],
);
} else {
appBar = topBar;
primaryBody = Center(
child: Column(children: [
Expanded(child: rotatingLogo),
serverUrl,
Expanded(flex: 2, child: form),
bottom,
]),
);
}
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: appBar,
body: SafeArea(
child: ImAdaptiveScaffoldBody(
primaryBody: (_) => primaryBody,
secondaryBody: (_) => secondaryBody,
),
),
);
}
}
class _MobileAppBar extends StatelessWidget implements PreferredSizeWidget {
const _MobileAppBar();
@override
Widget build(BuildContext context) {
return AppBar(
automaticallyImplyLeading: false,
scrolledUnderElevation: 0.0,
actions: [
IconButton(
onPressed: () => context.navigateRoot(const SettingsRoute()),
icon: const Icon(Symbols.settings),
),
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/login.service.dart';
import 'package:immich_mobile/domain/store_manager.dart';
import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/mixins/log_context.mixin.dart';
import 'package:immich_mobile/utils/snackbar_manager.dart';
class LoginPageCubit extends Cubit<LoginPageState> with LogContext {
LoginPageCubit() : super(LoginPageState.reset());
String _appendSchema(String url) {
// Add schema if none is set
url =
url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url";
// Remove trailing slash(es)
url = url.trimRight().replaceFirst(RegExp(r"/+$"), "");
return url;
}
String? validateServerUrl(String? url) {
if (url == null || url.isEmpty) {
return t.login.error.empty_server_url;
}
url = _appendSchema(url);
final uri = Uri.tryParse(url);
if (uri == null ||
!uri.isAbsolute ||
uri.host.isEmpty ||
!uri.scheme.startsWith("http")) {
return t.login.error.invalid_server_url;
}
return null;
}
Future<void> validateServer(String url) async {
url = _appendSchema(url);
final LoginService loginService = di();
try {
// parse instead of tryParse since the method expects a valid well formed URI
final uri = Uri.parse(url);
emit(state.copyWith(isValidationInProgress: true));
// Check if the endpoint is available
if (!await loginService.isEndpointAvailable(uri)) {
SnackbarManager.showError(t.login.error.server_not_reachable);
return;
}
// Check for /.well-known/immich
url = await loginService.resolveEndpoint(uri);
di<StoreManager>().put(StoreKey.serverEndpoint, url);
ServiceLocator.registerPostValidationServices(url);
// Fetch server features
await di<ServerFeatureConfigCubit>().getFeatures();
emit(state.copyWith(isServerValidated: true));
} finally {
emit(state.copyWith(isValidationInProgress: false));
}
}
Future<void> passwordLogin({
required String email,
required String password,
}) async {
emit(state.copyWith(isValidationInProgress: true));
final url = di<StoreManager>().get(StoreKey.serverEndpoint);
}
Future<void> oAuthLogin() async {
emit(state.copyWith(isValidationInProgress: true));
final url = di<StoreManager>().get(StoreKey.serverEndpoint);
}
void resetServerValidation() {
emit(LoginPageState.reset());
}
}
@@ -0,0 +1,187 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/domain/models/server-info/server_feature_config.model.dart';
import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/components/common/gap.widget.dart';
import 'package:immich_mobile/presentation/components/common/loading_indaticator.widget.dart';
import 'package:immich_mobile/presentation/components/input/filled_button.widget.dart';
import 'package:immich_mobile/presentation/components/input/password_form_field.widget.dart';
import 'package:immich_mobile/presentation/components/input/text_button.widget.dart';
import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart';
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:material_symbols_icons/symbols.dart';
class LoginForm extends StatelessWidget {
final TextEditingController serverUrlController;
final TextEditingController emailController;
final TextEditingController passwordController;
const LoginForm({
super.key,
required this.serverUrlController,
required this.emailController,
required this.passwordController,
});
@override
Widget build(BuildContext context) {
return BlocSelector<LoginPageCubit, LoginPageState, bool>(
selector: (model) => model.isServerValidated,
builder: (_, isServerValidated) => AnimatedSwitcher(
duration: Durations.medium1,
child: SingleChildScrollView(
child: isServerValidated
? _CredentialsPage(
emailController: emailController,
passwordController: passwordController,
)
: _ServerPage(controller: serverUrlController),
),
layoutBuilder: (current, previous) =>
current ?? (previous.lastOrNull ?? const SizedBox.shrink()),
),
);
}
}
class _ServerPage extends StatelessWidget {
final TextEditingController controller;
final GlobalKey<FormState> _formKey = GlobalKey();
_ServerPage({required this.controller});
Future<void> _validateForm(BuildContext context) async {
if (_formKey.currentState?.validate() == true) {
await context.read<LoginPageCubit>().validateServer(controller.text);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: BlocSelector<LoginPageCubit, LoginPageState, bool>(
selector: (model) => model.isValidationInProgress,
builder: (_, isValidationInProgress) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ImTextFormField(
controller: controller,
label: context.t.login.label.endpoint,
validator: context.read<LoginPageCubit>().validateServerUrl,
autoFillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
isDisabled: isValidationInProgress,
),
const SizedGap.mh(),
ImFilledButton(
label: context.t.login.label.next_button,
icon: Symbols.arrow_forward_rounded,
onPressed: () => unawaited(_validateForm(context)),
isDisabled: isValidationInProgress,
),
const SizedGap.mh(),
if (isValidationInProgress) const ImLoadingIndicator(),
],
),
),
);
}
}
class _CredentialsPage extends StatefulWidget {
final TextEditingController emailController;
final TextEditingController passwordController;
const _CredentialsPage({
required this.emailController,
required this.passwordController,
});
@override
State<_CredentialsPage> createState() => _CredentialsPageState();
}
class _CredentialsPageState extends State<_CredentialsPage> {
final passwordFocusNode = FocusNode();
@override
void dispose() {
passwordFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<LoginPageCubit, LoginPageState, bool>(
selector: (model) => model.isValidationInProgress,
builder: (_, isValidationInProgress) => isValidationInProgress
? const ImLoadingIndicator()
: BlocBuilder<ServerFeatureConfigCubit, ServerFeatureConfig>(
bloc: di(),
builder: (_, state) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (state.features.hasPasswordLogin) ...[
ImTextFormField(
label: context.t.login.label.email,
isDisabled: isValidationInProgress,
textInputAction: TextInputAction.next,
onSubmitted: (_) => passwordFocusNode.requestFocus(),
),
const SizedGap.mh(),
ImPasswordFormField(
label: context.t.login.label.password,
focusNode: passwordFocusNode,
isDisabled: isValidationInProgress,
textInputAction: TextInputAction.go,
),
const SizedGap.mh(),
ImFilledButton(
label: context.t.login.label.login_button,
icon: Symbols.login_rounded,
onPressed: () =>
context.read<LoginPageCubit>().passwordLogin(
email: widget.emailController.text,
password: widget.passwordController.text,
),
),
// Divider when both password and oAuth login is enabled
if (state.features.hasOAuthLogin) const Divider(),
],
if (state.features.hasOAuthLogin)
ImFilledButton(
label: state.config.oauthButtonText ??
context.t.login.label.oauth_button,
icon: Symbols.pin_rounded,
onPressed: () => unawaited(
context.read<LoginPageCubit>().oAuthLogin(),
),
),
if (!state.features.hasPasswordLogin &&
!state.features.hasOAuthLogin)
ImFilledButton(
label: context.t.login.label.login_disabled,
isDisabled: true,
),
const SizedGap.sh(),
ImTextButton(
label: context.t.login.label.back_button,
icon: Symbols.arrow_back_rounded,
onPressed:
context.read<LoginPageCubit>().resetServerValidation,
),
],
),
),
);
}
}
@@ -0,0 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
@RoutePage()
class LogsPage extends StatelessWidget {
const LogsPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: Text("Logs Page")));
}
}
@@ -0,0 +1,32 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/router/router.dart';
import 'package:material_symbols_icons/symbols.dart';
enum SettingSection {
general(
icon: Symbols.interests_rounded,
labelKey: 'settings.sections.general',
destination: GeneralSettingsRoute(),
),
advance(
icon: Symbols.build_rounded,
labelKey: 'settings.sections.advance',
destination: AdvanceSettingsRoute(),
),
about(
icon: Symbols.help_rounded,
labelKey: 'settings.sections.about',
destination: AboutSettingsRoute(),
);
final PageRouteInfo destination;
final String labelKey;
final IconData icon;
const SettingSection({
required this.labelKey,
required this.icon,
required this.destination,
});
}
@@ -0,0 +1,16 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_secondary_appbar.widget.dart';
@RoutePage()
class AboutSettingsPage extends StatelessWidget {
const AboutSettingsPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
appBar: ImAdaptiveRouteSecondaryAppBar(),
body: Center(child: Text('About Settings')),
);
}
}
@@ -0,0 +1,16 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_secondary_appbar.widget.dart';
@RoutePage()
class AdvanceSettingsPage extends StatelessWidget {
const AdvanceSettingsPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
appBar: ImAdaptiveRouteSecondaryAppBar(),
body: Center(child: Text('Advanced Settings')),
);
}
}
@@ -0,0 +1,16 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_secondary_appbar.widget.dart';
@RoutePage()
class GeneralSettingsPage extends StatelessWidget {
const GeneralSettingsPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
appBar: ImAdaptiveRouteSecondaryAppBar(),
body: Center(child: Text('General Settings')),
);
}
}
@@ -1,5 +1,25 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_primary_appbar.widget.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_wrapper.widget.dart';
import 'package:immich_mobile/presentation/modules/settings/models/settings_section.model.dart';
import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
@RoutePage()
class SettingsWrapperPage extends StatelessWidget {
const SettingsWrapperPage({super.key});
@override
Widget build(BuildContext context) {
return ImAdaptiveRouteWrapper(
primaryBody: (_) => const SettingsPage(),
primaryRoute: SettingsRoute.name,
bodyRatio: 0.3,
);
}
}
@RoutePage()
class SettingsPage extends StatelessWidget {
@@ -7,6 +27,21 @@ class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container();
return Scaffold(
appBar: const ImAdaptiveRoutePrimaryAppBar(),
body: ListView.builder(
itemCount: SettingSection.values.length,
itemBuilder: (_, index) {
final section = SettingSection.values.elementAt(index);
return ListTile(
title: Text(context.t[section.labelKey]),
onTap: () {
context.navigateRoot(section.destination);
},
leading: Icon(section.icon),
);
},
),
);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
@immutable
abstract class AppColors {
const AppColors();
/// Blue color
static const ColorScheme blueLight = ColorScheme(
brightness: Brightness.light,
primary: Color(0xff1145a4),
onPrimary: Color(0xffffffff),
primaryContainer: Color(0xffdae2ff),
onPrimaryContainer: Color(0xff001848),
secondary: Color(0xff4b73d3),
onSecondary: Color(0xfffefbff),
secondaryContainer: Color(0xffeef0ff),
onSecondaryContainer: Color(0xff001848),
tertiary: Color(0xff814b81),
onTertiary: Color(0xfffffbff),
tertiaryContainer: Color(0xffffd6fa),
onTertiaryContainer: Color(0xff340439),
error: Color(0xffba1a1a),
onError: Color(0xfffffbff),
errorContainer: Color(0xffffdad6),
onErrorContainer: Color(0xff410002),
surface: Color(0xfffefbff),
onSurface: Color(0xff1a1b21),
onSurfaceVariant: Color(0xff444651),
surfaceContainerHighest: Color(0xffe0e2ef),
outline: Color(0xff747782),
outlineVariant: Color(0xffc4c6d3),
shadow: Color(0xff000000),
scrim: Color(0xff000000),
inverseSurface: Color(0xff2f3036),
onInverseSurface: Color(0xfff1f0f7),
inversePrimary: Color(0xffb2c5ff),
surfaceTint: Color(0xff06409f),
);
static const ColorScheme blueDark = ColorScheme(
brightness: Brightness.dark,
primary: Color(0xffa9c7ff),
onPrimary: Color(0xff001b3d),
primaryContainer: Color(0xff00468c),
onPrimaryContainer: Color(0xffd6e3ff),
secondary: Color(0xffd6e3ff),
onSecondary: Color(0xff001b3d),
secondaryContainer: Color(0xff003063),
onSecondaryContainer: Color(0xffd6e3ff),
tertiary: Color(0xffeab4f6),
onTertiary: Color(0xff310540),
tertiaryContainer: Color(0xff61356e),
onTertiaryContainer: Color(0xfffad7ff),
error: Color(0xffffb4ab),
onError: Color(0xff410002),
errorContainer: Color(0xff93000a),
onErrorContainer: Color(0xffffb4ab),
surface: Color(0xff1a1e22),
onSurface: Color(0xffe2e2e9),
onSurfaceVariant: Color(0xffc2c6d2),
surfaceContainerHighest: Color(0xff424852),
outline: Color(0xff8c919c),
outlineVariant: Color(0xff424751),
shadow: Color(0xff000000),
scrim: Color(0xff000000),
inverseSurface: Color(0xffe1e1e9),
onInverseSurface: Color(0xff2e3036),
inversePrimary: Color(0xff005db7),
surfaceTint: Color(0xffa9c7ff),
);
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/modules/theme/models/app_colors.model.dart';
import 'package:immich_mobile/utils/extensions/material_state.extension.dart';
enum AppTheme {
blue(AppColors.blueLight, AppColors.blueDark),
// Fallback color for dynamic theme for non-supported platforms
dynamic(AppColors.blueLight, AppColors.blueDark);
final ColorScheme lightSchema;
final ColorScheme darkSchema;
const AppTheme(this.lightSchema, this.darkSchema);
static ThemeData generateThemeData(ColorScheme color) {
return ThemeData(
colorScheme: color,
primaryColor: color.primary,
iconTheme: const IconThemeData(weight: 500, opticalSize: 24),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: color.surface,
indicatorColor: color.primary,
iconTheme: WidgetStateProperty.resolveWith(
(Set<WidgetState> states) {
if (states.isSelected) {
return IconThemeData(color: color.onPrimary);
}
return IconThemeData(color: color.onSurface.withAlpha(175));
},
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: color.surface,
elevation: 3,
indicatorColor: color.primary,
selectedIconTheme:
IconThemeData(weight: 500, opticalSize: 24, color: color.onPrimary),
unselectedIconTheme: IconThemeData(
weight: 500,
opticalSize: 24,
color: color.onSurface.withAlpha(175),
),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
sliderTheme: SliderThemeData(
valueIndicatorColor:
Color.alphaBlend(color.primary.withAlpha(80), color.onSurface)
.withAlpha(240),
),
snackBarTheme: SnackBarThemeData(
elevation: 4,
behavior: SnackBarBehavior.floating,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4.0)),
),
insetPadding: const EdgeInsets.fromLTRB(20.0, 5.0, 20.0, 25.0),
backgroundColor:
Color.alphaBlend(color.primary.withAlpha(80), color.onSurface)
.withAlpha(240),
actionTextColor: color.inversePrimary,
contentTextStyle: TextStyle(color: color.onInverseSurface),
closeIconColor: color.onInverseSurface,
),
);
}
}
@@ -0,0 +1,23 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/domain/models/app_setting.model.dart';
import 'package:immich_mobile/domain/services/app_setting.service.dart';
import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart';
class AppThemeCubit extends Cubit<AppTheme> {
final AppSettingService _appSettings;
StreamSubscription? _appSettingSubscription;
AppThemeCubit(this._appSettings) : super(AppTheme.blue) {
_appSettingSubscription = _appSettings
.watchSetting(AppSetting.appTheme)
.listen((theme) => emit(theme));
}
@override
Future<void> close() {
_appSettingSubscription?.cancel();
return super.close();
}
}
@@ -0,0 +1,43 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart';
class AppThemeBuilder extends StatelessWidget {
const AppThemeBuilder({
super.key,
required this.theme,
required this.builder,
});
/// Current app theme to switch the theme data used
final AppTheme theme;
/// Builds the child widget of this widget, providing a light and dark [ThemeData] based on the
/// [theme] passed.
final Widget Function(
BuildContext context,
ThemeData lightTheme,
ThemeData darkTheme,
) builder;
@override
Widget build(BuildContext context) {
// Static colors
if (theme != AppTheme.dynamic) {
final lightTheme = AppTheme.generateThemeData(theme.lightSchema);
final darkTheme = AppTheme.generateThemeData(theme.darkSchema);
return builder(context, lightTheme, darkTheme);
}
// Dynamic color builder
return DynamicColorBuilder(builder: (lightDynamic, darkDynamic) {
final lightTheme =
AppTheme.generateThemeData(lightDynamic ?? theme.lightSchema);
final darkTheme =
AppTheme.generateThemeData(darkDynamic ?? theme.darkSchema);
return builder(context, lightTheme, darkTheme);
});
}
}