Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,17 @@
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/timezone.dart';
extension TZExtension on Asset {
/// Returns the created time of the asset from the exif info (if available) or from
/// the fileCreatedAt field, adjusted to the timezone value from the exif info along with
/// the timezone offset in [Duration]
(DateTime, Duration) getTZAdjustedTimeAndOffset() {
DateTime dt = fileCreatedAt.toLocal();
if (exifInfo?.dateTimeOriginal != null) {
return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone);
}
return (dt, dt.timeZoneOffset);
}
}

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/common/scaffold_error_body.dart';
import 'package:logging/logging.dart';
extension LogOnError<T> on AsyncValue<T> {
static final Logger _asyncErrorLogger = Logger("AsyncValue");
/// Used to return the [ImmichLoadingIndicator] and [ScaffoldErrorBody] widgets by default on loading
/// and error cases respectively
Widget widgetWhen({
bool skipLoadingOnRefresh = true,
Widget Function()? onLoading,
Widget Function(Object? error, StackTrace? stack)? onError,
required Widget Function(T data) onData,
}) {
if (isLoading) {
bool skip = false;
if (isRefreshing) {
skip = skipLoadingOnRefresh;
}
if (!skip) {
return onLoading?.call() ?? const Center(child: ImmichLoadingIndicator());
}
}
if (hasError && !hasValue) {
_asyncErrorLogger.severe('Could not load value', error, stackTrace);
return onError?.call(error, stackTrace) ?? ScaffoldErrorBody(errorMsg: error?.toString());
}
return onData(requireValue);
}
}

View file

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
extension ContextHelper on BuildContext {
// Returns the current padding from MediaQuery
EdgeInsets get padding => MediaQuery.paddingOf(this);
// Returns the current view insets from MediaQuery
EdgeInsets get viewInsets => MediaQuery.viewInsetsOf(this);
// Returns the current width from MediaQuery
double get width => MediaQuery.sizeOf(this).width;
// Returns the current height from MediaQuery
double get height => MediaQuery.sizeOf(this).height;
// Returns the current size from MediaQuery
Size get sizeData => MediaQuery.sizeOf(this);
// Returns true if the app is running on a mobile device (!tablets)
bool get isMobile => width < 550;
// Returns the current device pixel ratio from MediaQuery
double get devicePixelRatio => MediaQuery.devicePixelRatioOf(this);
// Returns the current orientation from MediaQuery
Orientation get orientation => MediaQuery.orientationOf(this);
// Returns the current platform brightness from MediaQuery
Brightness get platformBrightness => MediaQuery.platformBrightnessOf(this);
// Returns the current ThemeData
ThemeData get themeData => Theme.of(this);
// Returns true if the app is using a dark theme
bool get isDarkTheme => themeData.brightness == Brightness.dark;
// Returns the current Primary color of the Theme
Color get primaryColor => themeData.colorScheme.primary;
Color get logoYellow => const Color.fromARGB(255, 255, 184, 0);
Color get logoRed => const Color.fromARGB(255, 230, 65, 30);
Color get logoPink => const Color.fromARGB(255, 222, 127, 179);
Color get logoGreen => const Color.fromARGB(255, 49, 164, 82);
// Returns the Scaffold background color of the Theme
Color get scaffoldBackgroundColor => colorScheme.surface;
// Returns the current TextTheme
TextTheme get textTheme => themeData.textTheme;
// Current ColorScheme used
ColorScheme get colorScheme => themeData.colorScheme;
// Navigate by pushing or popping routes from the current context
NavigatorState get navigator => Navigator.of(this);
// Showing material banners from the current context
ScaffoldMessengerState get scaffoldMessenger => ScaffoldMessenger.of(this);
// Pop-out from the current context with optional result
void pop<T>([T? result]) => Navigator.of(this).pop(result);
// Managing focus within the widget tree from the current context
FocusScopeNode get focusScope => FocusScope.of(this);
// Show SnackBars from the current context
void showSnackBar(SnackBar snackBar) => ScaffoldMessenger.of(this).showSnackBar(snackBar);
}

View file

@ -0,0 +1,10 @@
import 'dart:ui';
import 'package:flutter/painting.dart';
extension CodecImageInfoExtension on Codec {
Future<ImageInfo> getImageInfo({double scale = 1.0}) async {
final frame = await getNextFrame();
return ImageInfo(image: frame.image, scale: scale);
}
}

View file

@ -0,0 +1,72 @@
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/hash.dart';
extension ListExtension<E> on List<E> {
List<E> uniqueConsecutive({int Function(E a, E b)? compare, void Function(E a, E b)? onDuplicate}) {
compare ??= (E a, E b) => a == b ? 0 : 1;
int i = 1, j = 1;
for (; i < length; i++) {
if (compare(this[i - 1], this[i]) != 0) {
if (i != j) {
this[j] = this[i];
}
j++;
} else if (onDuplicate != null) {
onDuplicate(this[i - 1], this[i]);
}
}
length = length == 0 ? 0 : j;
return this;
}
ListSlice<E> nestedSlice(int start, int end) {
if (this is ListSlice) {
final ListSlice<E> self = this as ListSlice<E>;
return ListSlice<E>(self.source, self.start + start, self.start + end);
}
return ListSlice<E>(this, start, end);
}
}
extension IntListExtension on Iterable<int> {
Int64List toInt64List() {
final list = Int64List(length);
list.setAll(0, this);
return list;
}
}
extension AssetListExtension on Iterable<Asset> {
/// Returns the assets that are already available in the Immich server
Iterable<Asset> remoteOnly({void Function()? errorCallback}) {
final bool onlyRemote = every((e) => e.isRemote);
if (!onlyRemote) {
if (errorCallback != null) errorCallback();
return where((a) => a.isRemote);
}
return this;
}
/// Returns the assets that are owned by the user passed to the [owner] param
/// If [owner] is null, an empty list is returned
Iterable<Asset> ownedOnly(UserDto? owner, {void Function()? errorCallback}) {
if (owner == null) return [];
final isarUserId = fastHash(owner.id);
final bool onlyOwned = every((e) => e.ownerId == isarUserId);
if (!onlyOwned) {
if (errorCallback != null) errorCallback();
return where((a) => a.ownerId == isarUserId);
}
return this;
}
}
extension SortedByProperty<T> on Iterable<T> {
Iterable<T> sortedByField(Comparable Function(T e) key) {
return sorted((a, b) => key(a).compareTo(key(b)));
}
}

View file

@ -0,0 +1,87 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
extension TimeAgoExtension on DateTime {
/// Displays the time difference of this [DateTime] object to the current time as a [String]
String timeAgo({bool numericDates = true}) {
DateTime date = toLocal();
final now = DateTime.now().toLocal();
final difference = now.difference(date);
if (difference.inSeconds < 5) {
return 'Just now';
} else if (difference.inSeconds < 60) {
return '${difference.inSeconds} seconds ago';
} else if (difference.inMinutes <= 1) {
return (numericDates) ? '1 minute ago' : 'A minute ago';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes} minutes ago';
} else if (difference.inHours <= 1) {
return (numericDates) ? '1 hour ago' : 'An hour ago';
} else if (difference.inHours < 60) {
return '${difference.inHours} hours ago';
} else if (difference.inDays <= 1) {
return (numericDates) ? '1 day ago' : 'Yesterday';
} else if (difference.inDays < 6) {
return '${difference.inDays} days ago';
} else if ((difference.inDays / 7).ceil() <= 1) {
return (numericDates) ? '1 week ago' : 'Last week';
} else if ((difference.inDays / 7).ceil() < 4) {
return '${(difference.inDays / 7).ceil()} weeks ago';
} else if ((difference.inDays / 30).ceil() <= 1) {
return (numericDates) ? '1 month ago' : 'Last month';
} else if ((difference.inDays / 30).ceil() < 30) {
return '${(difference.inDays / 30).ceil()} months ago';
} else if ((difference.inDays / 365).ceil() <= 1) {
return (numericDates) ? '1 year ago' : 'Last year';
}
return '${(difference.inDays / 365).floor()} years ago';
}
}
/// Extension to format date ranges according to UI requirements
extension DateRangeFormatting on DateTime {
/// Formats a date range according to specific rules:
/// - Single date of this year: "Aug 28"
/// - Single date of other year: "Aug 28, 2023"
/// - Date range of this year: "Mar 23-May 31"
/// - Date range of other year: "Aug 28 - Sep 30, 2023"
/// - Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022"
static String formatDateRange(DateTime startDate, DateTime endDate, Locale? locale) {
final now = DateTime.now();
final currentYear = now.year;
final localeString = locale?.toString() ?? 'en_US';
// Check if it's a single date (same day)
if (startDate.year == endDate.year && startDate.month == endDate.month && startDate.day == endDate.day) {
if (startDate.year == currentYear) {
// Single date of this year: "Aug 28"
return DateFormat.MMMd(localeString).format(startDate);
} else {
// Single date of other year: "Aug 28, 2023"
return DateFormat.yMMMd(localeString).format(startDate);
}
}
// It's a date range
if (startDate.year == endDate.year) {
// Same year
if (startDate.year == currentYear) {
// Date range of this year: "Mar 23-May 31"
final startFormatted = DateFormat.MMMd(localeString).format(startDate);
final endFormatted = DateFormat.MMMd(localeString).format(endDate);
return '$startFormatted - $endFormatted';
} else {
// Date range of other year: "Aug 28 - Sep 30, 2023"
final startFormatted = DateFormat.MMMd(localeString).format(startDate);
final endFormatted = DateFormat.MMMd(localeString).format(endDate);
return '$startFormatted - $endFormatted, ${startDate.year}';
}
} else {
// Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022"
final startFormatted = DateFormat.yMMMd(localeString).format(startDate);
final endFormatted = DateFormat.yMMMd(localeString).format(endDate);
return '$startFormatted - $endFormatted';
}
}
}

View file

@ -0,0 +1,17 @@
extension TZOffsetExtension on Duration {
/// Formats the duration in the format of ±HH:MM
String formatAsOffset() =>
"${isNegative ? '-' : '+'}${inHours.abs().toString().padLeft(2, '0')}:${inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
}
extension DurationFormatExtension on Duration {
String format() {
final seconds = inSeconds.remainder(60).toString().padLeft(2, '0');
final minutes = inMinutes.remainder(60).toString().padLeft(2, '0');
if (inHours == 0) {
return "$minutes:$seconds";
}
final hours = inHours.toString().padLeft(2, '0');
return "$hours:$minutes:$seconds";
}
}

View file

@ -0,0 +1,20 @@
import 'package:maplibre_gl/maplibre_gl.dart';
extension WithinBounds on LatLngBounds {
/// Checks whether [point] is inside bounds
bool contains(LatLng point) {
final sw = point;
final ne = point;
return containsBounds(LatLngBounds(southwest: sw, northeast: ne));
}
/// Checks whether [bounds] is contained inside bounds
bool containsBounds(LatLngBounds bounds) {
final sw = bounds.southwest;
final ne = bounds.northeast;
return (sw.latitude >= southwest.latitude) &&
(ne.latitude <= northeast.latitude) &&
(sw.longitude >= southwest.longitude) &&
(ne.longitude <= northeast.longitude);
}
}

View file

@ -0,0 +1,89 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/services.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
extension MapMarkers on MapLibreMapController {
static var _completer = Completer()..complete();
Future<void> addGeoJSONSourceForMarkers(List<MapMarker> markers) async {
return addSource(
MapUtils.defaultSourceId,
GeojsonSourceProperties(data: MapUtils.generateGeoJsonForMarkers(markers.toList())),
);
}
Future<void> reloadAllLayersForMarkers(List<MapMarker> markers) async {
// Wait for previous reload to complete
if (!_completer.isCompleted) {
return _completer.future;
}
_completer = Completer();
// !! Make sure to remove layers before sources else the native
// maplibre library would crash when removing the source saying that
// the source is still in use
final existingLayers = await getLayerIds();
if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) {
await removeLayer(MapUtils.defaultHeatMapLayerId);
}
final existingSources = await getSourceIds();
if (existingSources.contains(MapUtils.defaultSourceId)) {
await removeSource(MapUtils.defaultSourceId);
}
await addGeoJSONSourceForMarkers(markers);
if (Platform.isAndroid) {
await addCircleLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
const CircleLayerProperties(
circleRadius: 10,
circleColor: "rgba(150,86,34,0.7)",
circleBlur: 1.0,
circleOpacity: 0.7,
circleStrokeWidth: 0.1,
circleStrokeColor: "rgba(203,46,19,0.5)",
circleStrokeOpacity: 0.7,
),
);
}
if (Platform.isIOS) {
await addHeatmapLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
MapUtils.defaultHeatMapLayerProperties,
);
}
_completer.complete();
}
Future<Symbol?> addMarkerAtLatLng(LatLng centre) async {
// no marker is displayed if asset-path is incorrect
try {
final ByteData bytes = await rootBundle.load("assets/location-pin.png");
await addImage("mapMarker", bytes.buffer.asUint8List());
return addSymbol(SymbolOptions(geometry: centre, iconImage: "mapMarker", iconSize: 0.15, iconAnchor: "bottom"));
} finally {
// no-op
}
}
Future<LatLngBounds> getBoundsFromPoint(Point<double> point, double distance) async {
final southWestPx = Point(point.x - distance, point.y + distance);
final northEastPx = Point(point.x + distance, point.y - distance);
final southWest = await toLatLng(southWestPx);
final northEast = await toLatLng(northEastPx);
return LatLngBounds(southwest: southWest, northeast: northEast);
}
}

View file

@ -0,0 +1,8 @@
import 'package:immich_mobile/platform/connectivity_api.g.dart';
extension NetworkCapabilitiesGetters on List<NetworkCapability> {
bool get hasCellular => contains(NetworkCapability.cellular);
bool get hasWifi => contains(NetworkCapability.wifi);
bool get hasVpn => contains(NetworkCapability.vpn);
bool get isUnmetered => contains(NetworkCapability.unmetered);
}

View file

@ -0,0 +1,9 @@
import 'package:flutter/foundation.dart';
extension CurrentPlatform on TargetPlatform {
@pragma('vm:prefer-inline')
static bool get isIOS => defaultTargetPlatform == TargetPlatform.iOS;
@pragma('vm:prefer-inline')
static bool get isAndroid => defaultTargetPlatform == TargetPlatform.android;
}

View file

@ -0,0 +1,5 @@
import 'package:http/http.dart';
extension LoggerExtension on Response {
String toLoggerString() => "Status: $statusCode $reasonPhrase\n\n$body";
}

View file

@ -0,0 +1,34 @@
import 'package:flutter/cupertino.dart';
// https://stackoverflow.com/a/74453792
class FastScrollPhysics extends ScrollPhysics {
const FastScrollPhysics({super.parent});
@override
FastScrollPhysics applyTo(ScrollPhysics? ancestor) {
return FastScrollPhysics(parent: buildParent(ancestor));
}
@override
SpringDescription get spring => const SpringDescription(mass: 1, stiffness: 402.49984375, damping: 40);
}
class FastClampingScrollPhysics extends ClampingScrollPhysics {
const FastClampingScrollPhysics({super.parent});
@override
FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
return FastClampingScrollPhysics(parent: buildParent(ancestor));
}
@override
SpringDescription get spring => const SpringDescription(
// When swiping between videos on Android, the placeholder of the first opened video
// can briefly be seen and cause a flicker effect if the video begins to initialize
// before the animation finishes - probably a bug in PhotoViewGallery's animation handling
// Making the animation faster is not just stylistic, but also helps to avoid this flicker
mass: 1,
stiffness: 1601.2499609375,
damping: 80,
);
}

View file

@ -0,0 +1,35 @@
import 'dart:convert';
extension StringExtension on String {
String capitalize() {
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
}
}
extension DurationExtension on String {
/// Parses and returns the string of format HH:MM:SS as a duration object else null
Duration? toDuration() {
try {
final parts = split(':').map((e) => double.parse(e).toInt()).toList(growable: false);
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
} catch (e) {
return null;
}
}
double toDouble() {
return double.parse(this);
}
int toInt() {
return int.parse(this);
}
}
Map<String, dynamic>? tryJsonDecode(dynamic json) {
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (e) {
return null;
}
}

View file

@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
extension ImmichColorSchemeExtensions on ColorScheme {
bool get _isDarkMode => brightness == Brightness.dark;
Color get onSurfaceSecondary => _isDarkMode ? onSurface.darken(amount: .3) : onSurface.lighten(amount: .3);
}
extension ColorExtensions on Color {
Color lighten({double amount = 0.1}) {
return Color.alphaBlend(Colors.white.withValues(alpha: amount), this);
}
Color darken({double amount = 0.1}) {
return Color.alphaBlend(Colors.black.withValues(alpha: amount), this);
}
}

View file

@ -0,0 +1,46 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:intl/message_format.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/debug_print.dart';
extension StringTranslateExtension on String {
String t({BuildContext? context, Map<String, Object>? args}) {
return _translateHelper(context, this, args);
}
}
extension TextTranslateExtension on Text {
Text t({BuildContext? context, Map<String, Object>? args}) {
return Text(
_translateHelper(context, data ?? '', args),
key: key,
style: style,
strutStyle: strutStyle,
textAlign: textAlign,
textDirection: textDirection,
locale: locale,
softWrap: softWrap,
overflow: overflow,
textScaler: textScaler,
maxLines: maxLines,
semanticsLabel: semanticsLabel,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
);
}
}
String _translateHelper(BuildContext? context, String key, [Map<String, Object>? args]) {
if (key.isEmpty) {
return '';
}
try {
final translatedMessage = key.tr(context: context);
return args != null
? MessageFormat(translatedMessage, locale: Intl.defaultLocale ?? 'en').format(args)
: translatedMessage;
} catch (e) {
dPrint(() => 'Translation failed for key "$key". Error: $e');
return key;
}
}