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,11 @@
import 'package:drift/drift.dart';
class LocalAlbum extends Table {
const LocalAlbum();
IntColumn get id => integer().autoIncrement()();
TextColumn get localId => text()();
TextColumn get name => text()();
DateTimeColumn get modifiedTime =>
dateTime().withDefault(currentDateAndTime)();
}
@@ -0,0 +1,19 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset.model.dart';
class LocalAsset extends Table {
const LocalAsset();
IntColumn get id => integer().autoIncrement()();
TextColumn get localId => text()();
TextColumn get name => text()();
TextColumn get checksum => text()();
IntColumn get height => integer()();
IntColumn get width => integer()();
IntColumn get type => intEnum<AssetType>()();
DateTimeColumn get createdTime => dateTime()();
DateTimeColumn get modifiedTime =>
dateTime().withDefault(currentDateAndTime)();
IntColumn get duration => integer().withDefault(const Constant(0))();
BoolColumn get isLivePhoto => boolean().withDefault(const Constant(false))();
}
@@ -2,14 +2,24 @@ import 'dart:async';
import 'package:immich_mobile/domain/models/store.model.dart';
abstract class IStoreRepository {
FutureOr<T?> getValue<T>(StoreKey key);
abstract class IStoreConverter<T, U> {
const IStoreConverter();
FutureOr<void> setValue<T>(StoreKey key, T value);
/// Converts the value T to the primitive type U supported by the Store
U toPrimitive(T value);
/// Converts the value back to T? from the primitive type U from the Store
T? fromPrimitive(U value);
}
abstract class IStoreRepository {
FutureOr<T?> getValue<T, U>(StoreKey<T, U> key);
FutureOr<bool> setValue<T, U>(StoreKey<T, U> key, T value);
FutureOr<void> deleteValue(StoreKey key);
Stream<T?> watchValue<T>(StoreKey key);
Stream<T?> watchValue<T, U>(StoreKey<T, U> key);
Stream<List<StoreValue>> watchStore();
@@ -0,0 +1,31 @@
import 'package:flutter/foundation.dart';
@immutable
class LocalAlbum {
final int id;
final String localId;
final String name;
final DateTime modifiedTime;
const LocalAlbum({
required this.id,
required this.localId,
required this.name,
required this.modifiedTime,
});
@override
bool operator ==(covariant LocalAlbum other) {
if (identical(this, other)) return true;
return other.hashCode == hashCode;
}
@override
int get hashCode {
return id.hashCode ^
localId.hashCode ^
name.hashCode ^
modifiedTime.hashCode;
}
}
@@ -1,10 +1,15 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart';
enum AppSettings<T> {
appTheme<int>(StoreKey.appTheme, 10);
enum AppSetting<T> {
appTheme<AppTheme>(StoreKey.appTheme, AppTheme.blue),
themeMode<ThemeMode>(StoreKey.themeMode, ThemeMode.system),
darkMode<bool>(StoreKey.darkMode, false);
const AppSettings(this.storeKey, this.defaultValue);
const AppSetting(this.storeKey, this.defaultValue);
final StoreKey storeKey;
// ignore: avoid-dynamic
final StoreKey<T, dynamic> storeKey;
final T defaultValue;
}
@@ -0,0 +1,82 @@
import 'package:flutter/foundation.dart';
enum AssetType {
// do not change this order!
other,
image,
video,
audio,
}
@immutable
class LocalAsset {
final int id;
final String localId;
final String name;
final String checksum;
final int height;
final int width;
final AssetType type;
final DateTime createdTime;
final DateTime modifiedTime;
final int duration;
final bool isLivePhoto;
const LocalAsset({
required this.id,
required this.localId,
required this.name,
required this.checksum,
required this.height,
required this.width,
required this.type,
required this.createdTime,
required this.modifiedTime,
required this.duration,
required this.isLivePhoto,
});
@override
String toString() {
return 'LocalAsset(id: $id, localId: $localId, name: $name, checksum: $checksum, height: $height, width: $width, type: $type, createdTime: $createdTime, modifiedTime: $modifiedTime, duration: $duration, isLivePhoto: $isLivePhoto)';
}
String toJSON() {
return """
{
"id": $id,
"localId": "$localId",
"name": "$name",
"checksum": "$checksum",
"height": $height,
"width": $width,
"type": "$type",
"createdTime": "$createdTime",
"modifiedTime": "$modifiedTime",
"duration": "$duration",
"isLivePhoto": "$isLivePhoto",
}""";
}
@override
bool operator ==(covariant LocalAsset other) {
if (identical(this, other)) return true;
return other.hashCode == hashCode;
}
@override
int get hashCode {
return id.hashCode ^
localId.hashCode ^
name.hashCode ^
checksum.hashCode ^
height.hashCode ^
width.hashCode ^
type.hashCode ^
createdTime.hashCode ^
modifiedTime.hashCode ^
duration.hashCode ^
isLivePhoto.hashCode;
}
}
@@ -1,7 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
/// Log levels according to dart logging [Level]
enum LogLevel {
// do not change this order!
all,
finest,
finer,
@@ -20,6 +22,7 @@ extension LevelExtension on Level {
LogLevel.info;
}
@immutable
class LogMessage {
final int id;
final String content;
@@ -0,0 +1,34 @@
import 'package:openapi/openapi.dart';
class ServerConfig {
final String? oauthButtonText;
const ServerConfig({this.oauthButtonText});
ServerConfig copyWith({String? oauthButtonText}) {
return ServerConfig(
oauthButtonText: oauthButtonText ?? this.oauthButtonText,
);
}
factory ServerConfig.fromDto(ServerConfigDto dto) => ServerConfig(
oauthButtonText:
dto.oauthButtonText.isEmpty ? null : dto.oauthButtonText,
);
const ServerConfig.reset() : oauthButtonText = null;
@override
String toString() =>
'ServerConfig(oauthButtonText: ${oauthButtonText ?? '<NULL>'})';
@override
bool operator ==(covariant ServerConfig other) {
if (identical(this, other)) return true;
return other.oauthButtonText == oauthButtonText;
}
@override
int get hashCode => oauthButtonText.hashCode;
}
@@ -0,0 +1,37 @@
import 'package:immich_mobile/domain/models/server-info/server_config.model.dart';
import 'package:immich_mobile/domain/models/server-info/server_features.model.dart';
class ServerFeatureConfig {
final ServerFeatures features;
final ServerConfig config;
const ServerFeatureConfig({required this.features, required this.config});
ServerFeatureConfig copyWith({
ServerFeatures? features,
ServerConfig? config,
}) {
return ServerFeatureConfig(
features: features ?? this.features,
config: config ?? this.config,
);
}
const ServerFeatureConfig.reset()
: features = const ServerFeatures.reset(),
config = const ServerConfig.reset();
@override
String toString() =>
'ServerFeatureConfig(features: $features, config: $config)';
@override
bool operator ==(covariant ServerFeatureConfig other) {
if (identical(this, other)) return true;
return other.features == features && other.config == config;
}
@override
int get hashCode => features.hashCode ^ config.hashCode;
}
@@ -0,0 +1,42 @@
import 'package:openapi/openapi.dart';
class ServerFeatures {
final bool hasPasswordLogin;
final bool hasOAuthLogin;
const ServerFeatures({
required this.hasPasswordLogin,
required this.hasOAuthLogin,
});
ServerFeatures copyWith({bool? hasPasswordLogin, bool? hasOAuthLogin}) {
return ServerFeatures(
hasPasswordLogin: hasPasswordLogin ?? this.hasPasswordLogin,
hasOAuthLogin: hasOAuthLogin ?? this.hasOAuthLogin,
);
}
factory ServerFeatures.fromDto(ServerFeaturesDto dto) => ServerFeatures(
hasPasswordLogin: dto.passwordLogin,
hasOAuthLogin: dto.oauth,
);
const ServerFeatures.reset()
: hasPasswordLogin = true,
hasOAuthLogin = false;
@override
String toString() =>
'ServerFeatures(hasPasswordLogin: $hasPasswordLogin, hasOAuthLogin: $hasOAuthLogin)';
@override
bool operator ==(covariant ServerFeatures other) {
if (identical(this, other)) return true;
return other.hasPasswordLogin == hasPasswordLogin &&
other.hasOAuthLogin == hasOAuthLogin;
}
@override
int get hashCode => hasPasswordLogin.hashCode ^ hasOAuthLogin.hashCode;
}
+37 -54
View File
@@ -1,19 +1,14 @@
/// Key for each possible value in the `Store`.
/// Defines the data type for each value
enum StoreKey {
appTheme(1000, type: int);
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/utils/store_converters.dart';
import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart';
const StoreKey(this.id, {required this.type});
@immutable
class StoreValue<T> {
final int id;
final Type type;
}
final T? value;
class StoreValue {
final int id;
final int? intValue;
final String? stringValue;
const StoreValue({required this.id, this.intValue, this.stringValue});
const StoreValue({required this.id, this.value});
@override
bool operator ==(covariant StoreValue other) {
@@ -23,45 +18,33 @@ class StoreValue {
}
@override
int get hashCode => id.hashCode ^ intValue.hashCode ^ stringValue.hashCode;
T? extract<T>(Type type) {
switch (type) {
case const (int):
return intValue as T?;
case const (bool):
return intValue == null ? null : (intValue! == 1) as T;
case const (DateTime):
return intValue == null
? null
: DateTime.fromMicrosecondsSinceEpoch(intValue!) as T;
case const (String):
return stringValue as T?;
default:
throw UnsupportedError("Unknown Store Key type");
}
}
static StoreValue of<T>(StoreKey key, T? value) {
int? i;
String? s;
switch (key.type) {
case const (int):
i = value as int?;
break;
case const (bool):
i = value == null ? null : (value == true ? 1 : 0);
break;
case const (DateTime):
i = value == null ? null : (value as DateTime).microsecondsSinceEpoch;
break;
case const (String):
s = value as String?;
break;
default:
throw UnsupportedError("Unknown Store Key type");
}
return StoreValue(id: key.id, intValue: i, stringValue: s);
}
int get hashCode => id.hashCode ^ value.hashCode;
}
/// Key for each possible value in the `Store`.
/// Also stores the converter to convert the value to and from the store and the type of value stored in the Store
enum StoreKey<T, U> {
serverEndpoint<String, String>(
0,
converter: StorePrimitiveConverter(),
type: String,
),
appTheme<AppTheme, int>(
1000,
converter: StoreEnumConverter(AppTheme.values),
type: int,
),
themeMode<ThemeMode, int>(
1001,
converter: StoreEnumConverter(ThemeMode.values),
type: int,
),
darkMode<bool, int>(1002, converter: StoreBooleanConverter(), type: int);
const StoreKey(this.id, {required this.converter, required this.type});
final int id;
/// Type is also stored here easily fetch it during runtime
final Type type;
final IStoreConverter<T, U> converter;
}
@@ -2,6 +2,8 @@ import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:immich_mobile/domain/entities/album.entity.dart';
import 'package:immich_mobile/domain/entities/asset.entity.dart';
import 'package:immich_mobile/domain/entities/log.entity.dart';
import 'package:immich_mobile/domain/entities/store.entity.dart';
import 'package:immich_mobile/domain/interfaces/database.interface.dart';
@@ -12,7 +14,7 @@ import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
import 'database.repository.drift.dart';
@DriftDatabase(tables: [Logs, Store])
@DriftDatabase(tables: [Logs, Store, LocalAlbum, LocalAsset])
class DriftDatabaseRepository extends $DriftDatabaseRepository
implements IDatabaseRepository<GeneratedDatabase> {
DriftDatabaseRepository() : super(_openConnection());
@@ -1,66 +1,89 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/entities/store.entity.drift.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/utils/mixins/log_context.mixin.dart';
class StoreDriftRepository implements IStoreRepository {
class StoreDriftRepository with LogContext implements IStoreRepository {
final DriftDatabaseRepository db;
const StoreDriftRepository(this.db);
@override
FutureOr<T?> getValue<T>(StoreKey key) async {
final value = await db.managers.store
FutureOr<T?> getValue<T, U>(StoreKey<T, U> key) async {
final storeData = await db.managers.store
.filter((s) => s.id.equals(key.id))
.getSingleOrNull();
return value?.toModel().extract(key.type);
return _getValueFromStoreData(key, storeData);
}
@override
FutureOr<void> setValue<T>(StoreKey key, T value) {
return db.transaction(() async {
final storeValue = StoreValue.of(key, value);
await db.into(db.store).insertOnConflictUpdate(StoreCompanion.insert(
id: Value(storeValue.id),
intValue: Value(storeValue.intValue),
stringValue: Value(storeValue.stringValue),
));
});
FutureOr<bool> setValue<T, U>(StoreKey<T, U> key, T value) async {
try {
await db.transaction(() async {
final storeValue = key.converter.toPrimitive(value);
final intValue = (key.type == int) ? storeValue as int : null;
final stringValue = (key.type == String) ? storeValue as String : null;
await db.into(db.store).insertOnConflictUpdate(StoreCompanion.insert(
id: Value(key.id),
intValue: Value(intValue),
stringValue: Value(stringValue),
));
});
return true;
} catch (e, s) {
log.severe("Cannot set store value - ${key.name}; id - ${key.id}", e, s);
return false;
}
}
@override
FutureOr<void> deleteValue(StoreKey key) {
return db.transaction(() async {
FutureOr<void> deleteValue(StoreKey key) async {
return await db.transaction(() async {
await db.managers.store.filter((s) => s.id.equals(key.id)).delete();
});
}
@override
Stream<List<StoreValue>> watchStore() {
return (db.select(db.store).map((s) => s.toModel())).watch();
return (db.select(db.store).map((s) {
final key = StoreKey.values.firstWhereOrNull((e) => e.id == s.id);
if (key != null) {
final value = _getValueFromStoreData(key, s);
return StoreValue(id: s.id, value: value);
}
return StoreValue(id: s.id, value: null);
})).watch();
}
@override
Stream<T?> watchValue<T>(StoreKey key) {
Stream<T?> watchValue<T, U>(StoreKey<T, U> key) {
return db.managers.store
.filter((s) => s.id.equals(key.id))
.watchSingleOrNull()
.map((e) => e?.toModel().extract(key.type));
.map((e) => _getValueFromStoreData(key, e));
}
@override
FutureOr<void> clearStore() {
return db.transaction(() async {
FutureOr<void> clearStore() async {
return await db.transaction(() async {
await db.managers.store.delete();
});
}
}
extension _StoreDataToStoreValue on StoreData {
StoreValue toModel() {
return StoreValue(id: id, intValue: intValue, stringValue: stringValue);
T? _getValueFromStoreData<T, U>(StoreKey<T, U> key, StoreData? data) {
final primitive = switch (key.type) {
const (int) => data?.intValue,
const (String) => data?.stringValue,
_ => null,
} as U?;
if (primitive != null) {
return key.converter.fromPrimitive(primitive);
}
return null;
}
}
@@ -1,22 +1,22 @@
import 'package:immich_mobile/domain/models/app_setting.model.dart';
import 'package:immich_mobile/domain/store_manager.dart';
class AppSettingsService {
class AppSettingService {
final StoreManager store;
const AppSettingsService(this.store);
const AppSettingService(this.store);
T getSetting<T>(AppSettings<T> setting) {
T getSetting<T>(AppSetting<T> setting) {
return store.get(setting.storeKey, setting.defaultValue);
}
void setSetting<T>(AppSettings<T> setting, T value) {
store.put(setting.storeKey, value);
Future<bool> setSetting<T>(AppSetting<T> setting, T value) async {
return await store.put(setting.storeKey, value);
}
Stream<T> watchSetting<T>(AppSettings<T> setting) {
Stream<T> watchSetting<T>(AppSetting<T> setting) {
return store
.watch<T>(setting.storeKey)
.watch(setting.storeKey)
.map((value) => value ?? setting.defaultValue);
}
}
@@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:immich_mobile/utils/mixins/log_context.mixin.dart';
import 'package:openapi/openapi.dart';
class LoginService with LogContext {
const LoginService();
Future<bool> isEndpointAvailable(Uri uri, {Dio? dio}) async {
String baseUrl = uri.toString();
if (!baseUrl.endsWith('/api')) {
baseUrl += '/api';
}
final serverAPI =
Openapi(dio: dio, basePathOverride: baseUrl, interceptors: [])
.getServerInfoApi();
try {
await serverAPI.pingServer(validateStatus: (status) => status == 200);
} catch (e) {
log.severe("Exception occured while validating endpoint", e);
return false;
}
return true;
}
Future<String> resolveEndpoint(Uri uri, {Dio? dio}) async {
final d = dio ?? Dio();
String baseUrl = uri.toString();
try {
// Check for well-known endpoint
final res = await d.get(
"$baseUrl/.well-known/immich",
options: Options(
headers: {"Accept": "application/json"},
validateStatus: (status) => status == 200,
),
);
final data = jsonDecode(res.data);
final endpoint = data['api']['endpoint'].toString();
// Full URL is relative to base
return endpoint.startsWith('/') ? "$baseUrl$endpoint" : endpoint;
} catch (e) {
log.fine("Could not locate /.well-known/immich at $baseUrl", e);
}
// No well-known, return the baseUrl
return baseUrl;
}
}
@@ -0,0 +1,38 @@
import 'package:immich_mobile/domain/models/server-info/server_config.model.dart';
import 'package:immich_mobile/domain/models/server-info/server_features.model.dart';
import 'package:immich_mobile/utils/mixins/log_context.mixin.dart';
import 'package:openapi/openapi.dart';
class ServerInfoService with LogContext {
final Openapi _api;
ServerInfoApi get _serverInfo => _api.getServerInfoApi();
ServerInfoService(this._api);
Future<ServerFeatures?> getServerFeatures() async {
try {
final response = await _serverInfo.getServerFeatures();
final dto = response.data;
if (dto != null) {
return ServerFeatures.fromDto(dto);
}
} catch (e, s) {
log.severe("Error while fetching server features", e, s);
}
return null;
}
Future<ServerConfig?> getServerConfig() async {
try {
final response = await _serverInfo.getServerConfig();
final dto = response.data;
if (dto != null) {
return ServerConfig.fromDto(dto);
}
} catch (e, s) {
log.severe("Error while fetching server config", e, s);
}
return null;
}
}
+21 -31
View File
@@ -1,8 +1,8 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/mixins/log_context.mixin.dart';
class StoreKeyNotFoundException implements Exception {
@@ -13,39 +13,32 @@ class StoreKeyNotFoundException implements Exception {
String toString() => "Key '${key.name}' not found in Store";
}
/// Key-value store for individual items enumerated in StoreKey.
/// Supports String, int and JSON-serializable Objects
/// Can be used concurrently from multiple isolates
/// Key-value cache for individual items enumerated in StoreKey.
class StoreManager with LogContext {
late final IStoreRepository _db;
// This cannot be final or else dart would bite when we access the field in the factory method
StreamSubscription? _subscription;
late final StreamSubscription _subscription;
final Map<int, dynamic> _cache = {};
StoreManager._internal();
static final StoreManager _instance = StoreManager._internal();
factory StoreManager(IStoreRepository db) {
if (_instance._subscription == null) {
_instance._db = db;
_instance._populateCache();
_instance._subscription =
_instance._db.watchStore().listen(_instance._onChangeListener);
}
return _instance;
StoreManager(IStoreRepository db) {
_db = db;
_subscription = _db.watchStore().listen(_onChangeListener);
_populateCache();
}
void dispose() {
_subscription?.cancel();
_subscription.cancel();
}
FutureOr<void> _populateCache() async {
for (StoreKey key in StoreKey.values) {
final StoreValue? value = await _db.getValue(key);
final value = await _db.getValue(key);
if (value != null) {
_cache[key.id] = value;
}
}
/// Signal ready once the cache is populated
di.signalReady(this);
}
/// clears all values from this store (cache and DB), only for testing!
@@ -55,11 +48,11 @@ class StoreManager with LogContext {
}
/// Returns the stored value for the given key (possibly null)
T? tryGet<T>(StoreKey key) => _cache[key.id] as T?;
T? tryGet<T, U>(StoreKey<T, U> key) => _cache[key.id] as T?;
/// Returns the stored value for the given key or if null the [defaultValue]
/// Throws a [StoreKeyNotFoundException] if both are null
T get<T>(StoreKey key, [T? defaultValue]) {
T get<T, U>(StoreKey<T, U> key, [T? defaultValue]) {
final value = _cache[key.id] ?? defaultValue;
if (value == null) {
throw StoreKeyNotFoundException(key);
@@ -68,17 +61,17 @@ class StoreManager with LogContext {
}
/// Watches a specific key for changes
Stream<T?> watch<T>(StoreKey key) => _db.watchValue(key);
Stream<T?> watch<T, U>(StoreKey<T, U> key) => _db.watchValue(key);
/// Stores the value synchronously in the cache and asynchronously in the DB
FutureOr<void> put<T>(StoreKey key, T value) async {
if (_cache[key.id] == value) return Future.value();
FutureOr<bool> put<T, U>(StoreKey<T, U> key, T value) async {
if (_cache[key.id] == value) return Future.value(true);
_cache[key.id] = value;
return await _db.setValue(key, value);
}
/// Removes the value synchronously from the cache and asynchronously from the DB
Future<void> delete(StoreKey key) async {
Future<void> delete<T, U>(StoreKey<T, U> key) async {
if (_cache[key.id] == null) return Future.value();
_cache.remove(key.id);
return await _db.deleteValue(key);
@@ -87,12 +80,9 @@ class StoreManager with LogContext {
/// Updates the state in cache if a value is updated in any isolate
void _onChangeListener(List<StoreValue>? data) {
if (data != null) {
for (StoreValue value in data) {
final key = StoreKey.values.firstWhereOrNull((e) => e.id == value.id);
if (key != null) {
_cache[value.id] = value.extract(key.type);
} else {
log.warning("No key available for value Id - ${value.id}");
for (StoreValue storeValue in data) {
if (storeValue.value != null) {
_cache[storeValue.id] = storeValue.value;
}
}
}
@@ -0,0 +1,33 @@
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
class StoreEnumConverter<T extends Enum> extends IStoreConverter<T, int> {
const StoreEnumConverter(this.values);
final List<T> values;
@override
T? fromPrimitive(int value) => values.elementAtOrNull(value);
@override
int toPrimitive(T value) => value.index;
}
class StoreBooleanConverter extends IStoreConverter<bool, int> {
const StoreBooleanConverter();
@override
bool fromPrimitive(int value) => value != 0;
@override
int toPrimitive(bool value) => value ? 1 : 0;
}
class StorePrimitiveConverter<T> extends IStoreConverter<T, T> {
const StorePrimitiveConverter();
@override
T fromPrimitive(T value) => value;
@override
T toPrimitive(T value) => value;
}
+24 -18
View File
@@ -1,18 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart';
import 'package:immich_mobile/presentation/modules/theme/states/app_theme.state.dart';
import 'package:immich_mobile/presentation/modules/theme/widgets/app_theme_builder.widget.dart';
import 'package:immich_mobile/presentation/router/router.dart';
import 'package:watch_it/watch_it.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/constants/globals.dart';
class ImmichApp extends StatefulWidget {
final ThemeData lightTheme;
final ThemeData darkTheme;
const ImmichApp({
required this.lightTheme,
required this.darkTheme,
super.key,
});
const ImmichApp({super.key});
@override
State createState() => _ImmichAppState();
@@ -21,15 +19,23 @@ class ImmichApp extends StatefulWidget {
class _ImmichAppState extends State<ImmichApp> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
final router = di<AppRouter>();
return MaterialApp.router(
locale: TranslationProvider.of(context).flutterLocale,
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: widget.lightTheme,
darkTheme: widget.darkTheme,
routerConfig: router.config(),
return TranslationProvider(
child: BlocBuilder<AppThemeCubit, AppTheme>(
bloc: di(),
builder: (_, appTheme) => AppThemeBuilder(
theme: appTheme,
builder: (ctx, lightTheme, darkTheme) => MaterialApp.router(
debugShowCheckedModeBanner: false,
locale: TranslationProvider.of(ctx).flutterLocale,
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: lightTheme,
darkTheme: darkTheme,
routerConfig: di<AppRouter>().config(),
scaffoldMessengerKey: kScafMessengerKey,
),
),
),
);
}
}
+2 -22
View File
@@ -1,34 +1,14 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/immich_app.dart';
import 'package:immich_mobile/presentation/theme/states/app_theme.state.dart';
import 'package:immich_mobile/presentation/theme/widgets/app_theme_builder.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:watch_it/watch_it.dart';
void main() {
// Ensure the bindings are initialized
WidgetsFlutterBinding.ensureInitialized();
// DI Injection
ServiceLocator.configureServices();
// Init localization
LocaleSettings.useDeviceLocale();
runApp(const MainWidget());
}
class MainWidget extends StatelessWidget with WatchItMixin {
const MainWidget({super.key});
@override
Widget build(BuildContext context) {
final appTheme = watchIt<AppThemeState>().value;
return TranslationProvider(
child: AppThemeBuilder(
theme: appTheme,
builder: (lightTheme, darkTheme) =>
ImmichApp(lightTheme: lightTheme, darkTheme: darkTheme),
),
);
}
runApp(const ImmichApp());
}
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/constants/size_constants.dart';
@immutable
class SizedGap extends SizedBox {
const SizedGap({super.key, super.height, super.width});
// Widgets to be used in Column
const SizedGap.sh({super.key}) : super(height: SizeConstants.s);
const SizedGap.mh({super.key}) : super(height: SizeConstants.m);
const SizedGap.lh({super.key}) : super(height: SizeConstants.l);
const SizedGap.xlh({super.key}) : super(height: SizeConstants.xl);
// Widgets to be used in Row
const SizedGap.sw({super.key}) : super(width: SizeConstants.s);
const SizedGap.mw({super.key}) : super(width: SizeConstants.m);
const SizedGap.lw({super.key}) : super(width: SizeConstants.l);
const SizedGap.xlw({super.key}) : super(width: SizeConstants.xl);
}
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
class ImLoadingIndicator extends StatelessWidget {
const ImLoadingIndicator({super.key, this.dimension, this.strokeWidth});
/// The size of the indicator with a default of 24
final double? dimension;
/// The width of the indicator with a default of 2
final double? strokeWidth;
@override
Widget build(BuildContext context) {
return SizedBox(
width: dimension ?? 24,
height: dimension ?? 24,
child: FittedBox(
child: CircularProgressIndicator(strokeWidth: strokeWidth ?? 2),
),
);
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/utils/constants/assets.gen.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
class ImLogo extends StatelessWidget {
const ImLogo({
this.width,
this.filterQuality = FilterQuality.high,
super.key,
});
/// The width of the image.
final double? width;
/// The rendering quality
final FilterQuality filterQuality;
@override
Widget build(BuildContext context) {
return Image(
width: width,
filterQuality: filterQuality,
semanticLabel: 'Immich Logo',
image: Assets.images.immichLogo.provider(),
isAntiAlias: true,
);
}
}
class ImLogoText extends StatelessWidget {
const ImLogoText({
super.key,
this.fontSize = 48,
this.filterQuality = FilterQuality.high,
});
final double fontSize;
/// The rendering quality
final FilterQuality filterQuality;
@override
Widget build(BuildContext context) {
return Image(
semanticLabel: 'Immich Logo Text',
image: (context.isDarkTheme
? Assets.images.immichTextDark.provider
: Assets.images.immichTextLight.provider)(),
width: fontSize * 4,
filterQuality: FilterQuality.high,
);
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
class ImFilledButton extends StatelessWidget {
const ImFilledButton({
super.key,
this.icon,
this.onPressed,
this.isDisabled = false,
required this.label,
}) : _tonal = false;
const ImFilledButton.tonal({
super.key,
this.icon,
this.onPressed,
this.isDisabled = false,
required this.label,
}) : _tonal = true;
/// Internal flag to switch between filled and tonal variant
final bool _tonal;
/// Should disable the button
final bool isDisabled;
/// Icon to display if [withIcon] is true
final IconData? icon;
/// Action to perform on Button press
final VoidCallback? onPressed;
/// Label to be displayed in the button
final String label;
@override
Widget build(BuildContext context) {
if (_tonal) {
if (icon != null) {
return FilledButton.tonalIcon(
onPressed: isDisabled ? null : onPressed,
icon: Icon(icon),
label: Text(label),
);
}
return FilledButton.tonal(
onPressed: isDisabled ? null : onPressed,
child: Text(label),
);
}
if (icon != null) {
return FilledButton.icon(
onPressed: isDisabled ? null : onPressed,
icon: Icon(icon),
label: Text(label),
);
}
return FilledButton(
onPressed: isDisabled ? null : onPressed,
child: Text(label),
);
}
}
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart';
import 'package:material_symbols_icons/symbols.dart';
class ImPasswordFormField extends StatefulWidget {
const ImPasswordFormField({
super.key,
this.controller,
this.onChanged,
this.focusNode,
this.label,
this.hint,
this.textInputAction,
this.isDisabled = false,
});
/// The [TextEditingController] passed to the underlying [TextFormField]
final TextEditingController? controller;
/// Optional callback to receive changes
final void Function(String?)? onChanged;
/// The [FocusNode] passed to the underlying [TextFormField]
final FocusNode? focusNode;
/// Translation Key used as label
final String? label;
/// Translation key used as hint
final String? hint;
/// Type of the following action - go, next, enter, etc.
final TextInputAction? textInputAction;
/// Flag to disable the [TextFormField]
final bool isDisabled;
@override
State createState() => _ImPasswordFormFieldState();
}
class _ImPasswordFormFieldState extends State<ImPasswordFormField> {
final showPassword = ValueNotifier(false);
@override
void dispose() {
showPassword.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: showPassword,
builder: (_, showPass, child) => ImTextFormField(
controller: widget.controller,
onChanged: widget.onChanged,
shouldObscure: !showPass,
hint: widget.hint,
label: widget.label,
focusNode: widget.focusNode,
suffixIcon: IconButton(
onPressed: () => showPassword.value = !showPassword.value,
icon: Icon(
showPassword.value
? Symbols.visibility_off_rounded
: Symbols.visibility_rounded,
),
),
autoFillHints: const [AutofillHints.password],
keyboardType: TextInputType.visiblePassword,
textInputAction: widget.textInputAction,
isDisabled: widget.isDisabled,
),
);
}
}
@@ -0,0 +1,62 @@
import 'dart:async';
import 'package:flutter/material.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/service_locator.dart';
class ImSwitchListTile<T> extends StatefulWidget {
const ImSwitchListTile(
this.setting, {
super.key,
this.fromAppSetting,
this.toAppSetting,
}) : assert(T == bool || (fromAppSetting != null && toAppSetting != null),
"Setting is not a boolean and a from / to App setting is not provided");
final AppSetting<T> setting;
/// Converts the type T to a boolean to use in a switch
final bool Function(T value)? fromAppSetting;
/// Converts the boolean back to the type T to be stored in the app setting. Return null to not update the DB but to
/// retain the previous value
final T? Function(bool state)? toAppSetting;
@override
State createState() => _ImSwitchListTileState<T>();
}
class _ImSwitchListTileState<T> extends State<ImSwitchListTile<T>> {
// Actual switch list state
late bool isEnabled;
final AppSettingService _appSettingService = di();
Future<void> set(bool enabled) async {
if (isEnabled == enabled) return;
final value = T != bool ? widget.toAppSetting!(enabled) : enabled as T;
if (value != null &&
await _appSettingService.setSetting(widget.setting, value) &&
context.mounted) {
setState(() {
isEnabled = enabled;
});
}
}
@override
void initState() {
super.initState();
final value = _appSettingService.getSetting(widget.setting);
isEnabled = T != bool ? widget.fromAppSetting!(value) : value as bool;
}
@override
Widget build(BuildContext context) {
return SwitchListTile(
value: isEnabled,
onChanged: (value) => set(value),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class ImTextButton extends StatelessWidget {
const ImTextButton({
super.key,
this.icon,
this.onPressed,
this.isDisabled = false,
required this.label,
});
/// Icon to display if [withIcon] is true
final IconData? icon;
/// Flag to disable the button
final bool isDisabled;
/// Action to perform on Button press
final VoidCallback? onPressed;
/// Label to be displayed in the button
final String label;
@override
Widget build(BuildContext context) {
if (icon != null) {
return TextButton.icon(
onPressed: isDisabled ? null : onPressed,
icon: Icon(icon),
label: Text(label),
);
}
return TextButton(onPressed: onPressed, child: Text(label));
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
class ImTextFormField extends StatelessWidget {
const ImTextFormField({
super.key,
this.controller,
this.focusNode,
this.onChanged,
this.validator,
this.shouldObscure = false,
this.suffixIcon,
this.label,
this.hint,
this.autoFillHints,
this.keyboardType,
this.textInputAction,
this.isDisabled = false,
this.onSubmitted,
}) : assert(
onSubmitted == null ||
textInputAction == TextInputAction.next ||
textInputAction == TextInputAction.previous,
"onSubmitted provided when textInputAction is not next or pervious",
);
/// The [TextEditingController] passed to the underlying [TextFormField]
final TextEditingController? controller;
/// The [FocusNode] passed to the underlying [TextFormField]
final FocusNode? focusNode;
/// Optional callback to validate input
final String? Function(String?)? validator;
/// Optional callback to receive changes
final void Function(String?)? onChanged;
/// Optional flag to obscure texts
final bool shouldObscure;
/// Icon Widget to display in the suffix
final Widget? suffixIcon;
/// Translation Key used as label
final String? label;
/// Translation key used as hint
final String? hint;
/// Hints used by the auto-fill service
final List<String>? autoFillHints;
/// Type of keyboard - Numberic / Alphanum
final TextInputType? keyboardType;
/// Type of the following action - go, next, enter, etc.
final TextInputAction? textInputAction;
/// Flag to disable the [TextFormField]
final bool isDisabled;
/// Called on [TextInputAction.next] or [TextInputAction.previous]
final void Function(String)? onSubmitted;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
onChanged: onChanged,
focusNode: focusNode,
obscureText: shouldObscure,
validator: validator,
decoration: InputDecoration(
labelText: label,
hintText: hint,
suffixIcon: suffixIcon,
),
autofillHints: autoFillHints,
keyboardType: keyboardType,
textInputAction: textInputAction,
readOnly: isDisabled,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onFieldSubmitted: onSubmitted,
);
}
}
@@ -0,0 +1,17 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
class ImAdaptiveRoutePrimaryAppBar extends StatelessWidget
implements PreferredSizeWidget {
const ImAdaptiveRoutePrimaryAppBar({super.key});
@override
Widget build(BuildContext context) {
return AppBar(
leading: BackButton(onPressed: () => context.router.root.maybePop()),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
@@ -0,0 +1,20 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget
implements PreferredSizeWidget {
const ImAdaptiveRouteSecondaryAppBar({super.key});
@override
Widget build(BuildContext context) {
return AppBar(
leading: context.isTablet
? CloseButton(onPressed: () => context.maybePop())
: BackButton(onPressed: () => context.maybePop()),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
@@ -0,0 +1,37 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_scaffold_body.widget.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
class ImAdaptiveRouteWrapper extends StatelessWidget {
const ImAdaptiveRouteWrapper({
super.key,
required this.primaryRoute,
required this.primaryBody,
this.bodyRatio,
});
/// Builder to build the primary body
final Widget Function(BuildContext?) primaryBody;
/// Primary route name to not render it twice in landscape
final String primaryRoute;
/// Ratio of primaryBody:secondaryBody
final double? bodyRatio;
@override
Widget build(BuildContext context) {
return AutoRouter(builder: (ctx, child) {
if (ctx.isTablet) {
return ImAdaptiveScaffoldBody(
primaryBody: primaryBody,
secondaryBody:
ctx.topRoute.name != primaryRoute ? (_) => child : null,
bodyRatio: bodyRatio,
);
}
return ImAdaptiveScaffoldBody(primaryBody: (_) => child);
});
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
class ImAdaptiveScaffoldBody extends StatelessWidget {
const ImAdaptiveScaffoldBody({
super.key,
required this.primaryBody,
this.secondaryBody,
this.bodyRatio,
});
/// Builder to build the primary body
final Widget Function(BuildContext?) primaryBody;
/// Builder to build the secondary body
final Widget Function(BuildContext?)? secondaryBody;
/// Ratio of primaryBody:secondaryBody
final double? bodyRatio;
@override
Widget build(BuildContext context) {
return AdaptiveLayout(
internalAnimations: false,
transitionDuration: const Duration(milliseconds: 300),
bodyRatio: bodyRatio,
body: SlotLayout(
config: {
Breakpoints.standard: SlotLayout.from(
key: const Key('ImAdaptiveScaffold Body Standard'),
builder: primaryBody,
),
},
),
secondaryBody: SlotLayout(
config: {
/// No secondary body in mobile layouts
Breakpoints.small: SlotLayoutConfig.empty(),
Breakpoints.mediumAndUp: SlotLayout.from(
key: const Key('ImAdaptiveScaffold Secondary Body Medium'),
builder: secondaryBody,
),
},
),
);
}
}
@@ -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();
}
}
@@ -1,6 +1,6 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/theme/utils/colors.dart';
import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart';
class AppThemeBuilder extends StatelessWidget {
const AppThemeBuilder({
@@ -14,26 +14,30 @@ class AppThemeBuilder extends StatelessWidget {
/// Builds the child widget of this widget, providing a light and dark [ThemeData] based on the
/// [theme] passed.
final Widget Function(ThemeData lightTheme, ThemeData darkTheme) builder;
final Widget Function(
BuildContext context,
ThemeData lightTheme,
ThemeData darkTheme,
) builder;
@override
Widget build(BuildContext context) {
// Static colors
if (theme != AppTheme.dynamic) {
final lightTheme = AppColors.getThemeForColorScheme(theme.lightSchema);
final darkTheme = AppColors.getThemeForColorScheme(theme.darkSchema);
final lightTheme = AppTheme.generateThemeData(theme.lightSchema);
final darkTheme = AppTheme.generateThemeData(theme.darkSchema);
return builder(lightTheme, darkTheme);
return builder(context, lightTheme, darkTheme);
}
// Dynamic color builder
return DynamicColorBuilder(builder: (lightDynamic, darkDynamic) {
final lightTheme =
AppColors.getThemeForColorScheme(lightDynamic ?? theme.lightSchema);
AppTheme.generateThemeData(lightDynamic ?? theme.lightSchema);
final darkTheme =
AppColors.getThemeForColorScheme(darkDynamic ?? theme.darkSchema);
AppTheme.generateThemeData(darkDynamic ?? theme.darkSchema);
return builder(lightTheme, darkTheme);
return builder(context, lightTheme, darkTheme);
});
}
}
@@ -0,0 +1,73 @@
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/image/immich_logo.widget.dart';
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/mixins/log_context.mixin.dart';
@RoutePage()
class SplashScreenWrapperPage extends AutoRouter implements AutoRouteWrapper {
const SplashScreenWrapperPage({super.key});
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(create: (_) => LoginPageCubit(), child: this);
}
}
@RoutePage()
class SplashScreenPage extends StatefulWidget {
const SplashScreenPage({super.key});
@override
State createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreenPage>
with SingleTickerProviderStateMixin, LogContext {
late final AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(seconds: 30),
vsync: this,
)..repeat();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder(
future: di.allReady(),
builder: (_, snap) {
if (snap.hasData) {
context.replaceRoute(const LoginRoute());
} else if (snap.hasError) {
log.severe(
"Error while initializing the app",
snap.error,
snap.stackTrace,
);
}
return Center(
child: RotationTransition(
turns: _animationController,
child: const ImLogo(width: 100),
),
);
},
),
);
}
}
+30 -3
View File
@@ -1,25 +1,52 @@
import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/presentation/modules/home/pages/home.page.dart';
import 'package:immich_mobile/presentation/modules/library/pages/library.page.dart';
import 'package:immich_mobile/presentation/modules/login/pages/login.page.dart';
import 'package:immich_mobile/presentation/modules/logs/pages/log.page.dart';
import 'package:immich_mobile/presentation/modules/search/pages/search.page.dart';
import 'package:immich_mobile/presentation/modules/settings/pages/about_settings.page.dart';
import 'package:immich_mobile/presentation/modules/settings/pages/advance_settings.page.dart';
import 'package:immich_mobile/presentation/modules/settings/pages/general_settings.page.dart';
import 'package:immich_mobile/presentation/modules/settings/pages/settings.page.dart';
import 'package:immich_mobile/presentation/modules/sharing/pages/sharing.page.dart';
import 'package:immich_mobile/presentation/router/pages/splash_screen.page.dart';
import 'package:immich_mobile/presentation/router/pages/tab_controller.page.dart';
part 'router.gr.dart';
@AutoRouterConfig(replaceInRouteName: 'Page,Route')
class AppRouter extends _$AppRouter {
class AppRouter extends _$AppRouter implements AutoRouteGuard {
AppRouter();
@override
List<AutoRoute> get routes => [
AutoRoute(page: TabControllerRoute.page, initial: true, children: [
AutoRoute(
page: SplashScreenWrapperRoute.page,
initial: true,
children: [
AutoRoute(page: SplashScreenRoute.page, initial: true),
AutoRoute(page: LoginRoute.page),
],
),
AutoRoute(page: LogsRoute.page),
AutoRoute(page: TabControllerRoute.page, children: [
AutoRoute(page: HomeRoute.page),
AutoRoute(page: SearchRoute.page),
AutoRoute(page: SharingRoute.page),
AutoRoute(page: LibraryRoute.page),
]),
AutoRoute(page: SettingsRoute.page),
AutoRoute(page: SettingsWrapperRoute.page, children: [
AutoRoute(page: SettingsRoute.page),
AutoRoute(page: GeneralSettingsRoute.page),
AutoRoute(page: AboutSettingsRoute.page),
AutoRoute(page: AdvanceSettingsRoute.page),
]),
];
// Global guards
@override
void onNavigation(NavigationResolver resolver, StackRouter router) {
// Prevent duplicates
resolver.next(resolver.route.name != router.current.name);
}
}
@@ -1,30 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.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/theme/utils/colors.dart';
class AppThemeState extends ValueNotifier<AppTheme> {
final AppSettingsService _appSettings;
StreamSubscription? _appSettingSubscription;
AppThemeState({required AppSettingsService appSettings})
: _appSettings = appSettings,
super(AppTheme.blue);
void init() {
_appSettingSubscription =
_appSettings.watchSetting(AppSettings.appTheme).listen((themeIndex) {
final theme =
AppTheme.values.elementAtOrNull(themeIndex) ?? AppTheme.blue;
value = theme;
});
}
@override
void dispose() {
_appSettingSubscription?.cancel();
return super.dispose();
}
}
@@ -1,92 +0,0 @@
import 'package:flutter/material.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);
}
class AppColors {
const AppColors();
/// Blue color
static const ColorScheme _blueLight = ColorScheme(
brightness: Brightness.light,
primary: Color(0xff1565c0),
onPrimary: Color(0xffffffff),
primaryContainer: Color(0xffd6e3ff),
onPrimaryContainer: Color(0xff001b3d),
secondary: Color(0xff3277d2),
onSecondary: Color(0xfffdfbff),
secondaryContainer: Color(0xffecf0ff),
onSecondaryContainer: Color(0xff001b3d),
tertiary: Color(0xff7b4d88),
onTertiary: Color(0xfffffbff),
tertiaryContainer: Color(0xfffad7ff),
onTertiaryContainer: Color(0xff310540),
error: Color(0xffba1a1a),
onError: Color(0xfffffbff),
errorContainer: Color(0xffffdad6),
onErrorContainer: Color(0xff410002),
background: Color(0xfffcfafe),
onBackground: Color(0xff191c20),
surface: Color(0xfffdfbff),
onSurface: Color(0xff191c20),
surfaceVariant: Color(0xffdfe2ef),
onSurfaceVariant: Color(0xff424751),
outline: Color(0xff737782),
outlineVariant: Color(0xffc2c6d2),
shadow: Color(0xff000000),
scrim: Color(0xff000000),
inverseSurface: Color(0xff2e3036),
onInverseSurface: Color(0xfff0f0f7),
inversePrimary: Color(0xffa9c7ff),
surfaceTint: Color(0xff00468c),
);
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),
background: Color(0xff1a1d21),
onBackground: Color(0xffe2e2e9),
surface: Color(0xff1a1e22),
onSurface: Color(0xffe2e2e9),
surfaceVariant: Color(0xff424852),
onSurfaceVariant: Color(0xffc2c6d2),
outline: Color(0xff8c919c),
outlineVariant: Color(0xff424751),
shadow: Color(0xff000000),
scrim: Color(0xff000000),
inverseSurface: Color(0xffe1e1e9),
onInverseSurface: Color(0xff2e3036),
inversePrimary: Color(0xff005db7),
surfaceTint: Color(0xffa9c7ff),
);
static ThemeData getThemeForColorScheme(ColorScheme color) {
return ThemeData(
primaryColor: color.primary,
iconTheme: const IconThemeData(weight: 400),
);
}
}
+41 -11
View File
@@ -1,13 +1,19 @@
import 'package:get_it/get_it.dart';
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/domain/repositories/log.repository.dart';
import 'package:immich_mobile/domain/repositories/store.repository.dart';
import 'package:immich_mobile/domain/services/app_setting.service.dart';
import 'package:immich_mobile/domain/services/login.service.dart';
import 'package:immich_mobile/domain/services/server_info.service.dart';
import 'package:immich_mobile/domain/store_manager.dart';
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
import 'package:immich_mobile/presentation/modules/theme/states/app_theme.state.dart';
import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/presentation/theme/states/app_theme.state.dart';
import 'package:watch_it/watch_it.dart';
import 'package:openapi/openapi.dart';
final di = GetIt.I;
class ServiceLocator {
const ServiceLocator._internal();
@@ -15,26 +21,50 @@ class ServiceLocator {
static void configureServices() {
// Register DB
di.registerSingleton<DriftDatabaseRepository>(DriftDatabaseRepository());
_registerDomainServices();
_registerPresentationService();
_registerPreValidationServices();
}
static void _registerDomainServices() {
static void _registerPreValidationServices() {
// ====== DOMAIN
// Init store
di.registerFactory<IStoreRepository>(() => StoreDriftRepository(di()));
di.registerSingleton<StoreManager>(StoreManager(di()));
// StoreManager populates its cache with a async gap, manually signalReady once the cache is populated.
di.registerSingleton<StoreManager>(StoreManager(di()), signalsReady: true);
// Logs
di.registerFactory<ILogRepository>(() => LogDriftRepository(di()));
// App Settings
di.registerFactory<AppSettingsService>(() => AppSettingsService(di()));
}
di.registerFactory<AppSettingService>(() => AppSettingService(di()));
// Login Service
di.registerFactory<LoginService>(() => const LoginService());
// ====== PRESENTATION
static void _registerPresentationService() {
// App router
di.registerSingleton<AppRouter>(AppRouter());
// Global states
di.registerLazySingleton<AppThemeState>(
() => AppThemeState(appSettings: di())..init(),
di.registerLazySingleton<AppThemeCubit>(() => AppThemeCubit(di()));
}
static void registerPostValidationServices(String endpoint) {
if (di.isRegistered<Openapi>()) {
return;
}
// ====== DOMAIN
di.registerSingleton<Openapi>(
Openapi(
basePathOverride: endpoint,
interceptors: [BearerAuthInterceptor()],
),
);
di.registerFactory<ServerInfoService>(() => ServerInfoService(di()));
// ====== PRESENTATION
di.registerLazySingleton<ServerFeatureConfigCubit>(
() => ServerFeatureConfigCubit(di()),
);
}
}
@@ -0,0 +1,4 @@
import 'package:flutter/material.dart';
/// Global ScaffoldMessengerKey to show snackbars
final GlobalKey<ScaffoldMessengerState> kScafMessengerKey = GlobalKey();
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
@immutable
class SizeConstants {
const SizeConstants._();
static const s = 8.0;
static const m = 16.0;
static const l = 32.0;
static const xl = 64.0;
}
@@ -0,0 +1,30 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
extension BuildContextHelper on BuildContext {
/// Get the current [ThemeData] used
ThemeData get theme => Theme.of(this);
/// Get the default [TextStyle]
TextStyle get defaultTextStyle => DefaultTextStyle.of(this).style;
/// Get the [Size] of [MediaQuery]
Size get mediaQuerySize => MediaQuery.sizeOf(this);
/// Get the [EdgeInsets] of [MediaQuery]
EdgeInsets get viewInsets => MediaQuery.viewInsetsOf(this);
/// True if the current device is a Tablet
bool get isTablet => (mediaQuerySize.width >= 600);
/// True if the current app theme is dark
bool get isDarkTheme => theme.brightness == Brightness.dark;
/// Navigate using the root router
// ignore: avoid-dynamic
Future<dynamic> navigateRoot(
PageRouteInfo route, {
OnNavigationFailure? onFailure,
}) =>
router.root.navigate(route, onFailure: onFailure);
}
@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
extension MaterialStateHelpers on Iterable<WidgetState> {
bool get isDisabled => contains(WidgetState.disabled);
bool get isDragged => contains(WidgetState.dragged);
bool get isError => contains(WidgetState.error);
bool get isFocused => contains(WidgetState.focused);
bool get isHovered => contains(WidgetState.hovered);
bool get isPressed => contains(WidgetState.pressed);
bool get isScrolledUnder => contains(WidgetState.scrolledUnder);
bool get isSelected => contains(WidgetState.selected);
}
@@ -2,13 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
mixin LogContext {
late final String ctx = logContext;
/// Context name of the log message
/// Override this to provide a custom name
String get logContext => runtimeType.toString();
@protected
@nonVirtual
Logger get log => Logger.detached(ctx);
Logger get log => Logger.detached(runtimeType.toString());
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/constants/globals.dart';
class SnackbarManager {
const SnackbarManager();
static ScaffoldMessengerState? get _s => kScafMessengerKey.currentState;
static void showError(String errorMsg) {
_s?.clearSnackBars();
_s?.showSnackBar(SnackBar(content: Text(errorMsg)));
}
}