add adaptive_scaffold
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+20
@@ -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,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+27
@@ -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();
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user