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,128 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:immich_mobile/widgets/map/map_settings_sheet.dart';
class MapAppBar extends HookWidget implements PreferredSizeWidget {
final ValueNotifier<Set<Asset>> selectedAssets;
const MapAppBar({super.key, required this.selectedAssets});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: context.padding.top + 25),
child: ValueListenableBuilder(
valueListenable: selectedAssets,
builder: (ctx, value, child) =>
value.isNotEmpty ? _SelectionRow(selectedAssets: selectedAssets) : const _NonSelectionRow(),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(100);
}
class _NonSelectionRow extends StatelessWidget {
const _NonSelectionRow();
@override
Widget build(BuildContext context) {
void onSettingsPressed() {
showModalBottomSheet(
elevation: 0.0,
showDragHandle: true,
isScrollControlled: true,
context: context,
builder: (_) => const MapSettingsSheet(),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
onPressed: () => context.maybePop(),
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.arrow_back_ios_new_rounded),
),
ElevatedButton(
onPressed: onSettingsPressed,
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.more_vert_rounded),
),
],
);
}
}
class _SelectionRow extends HookConsumerWidget {
final ValueNotifier<Set<Asset>> selectedAssets;
const _SelectionRow({required this.selectedAssets});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isProcessing = useProcessingOverlay();
Future<void> handleProcessing(FutureOr<void> Function() action, [bool reloadMarkers = false]) async {
isProcessing.value = true;
await action();
// Reset state
selectedAssets.value = {};
isProcessing.value = false;
if (reloadMarkers) {
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(true);
}
}
return Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 20),
child: ElevatedButton.icon(
onPressed: () => selectedAssets.value = {},
icon: const Icon(Icons.close_rounded),
label: Text(
'${selectedAssets.value.length}',
style: context.textTheme.titleMedium?.copyWith(color: context.colorScheme.onPrimary),
),
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton(
onPressed: () => handleProcessing(() => handleShareAssets(ref, context, selectedAssets.value.toList())),
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.ios_share_rounded),
),
ElevatedButton(
onPressed: () =>
handleProcessing(() => handleFavoriteAssets(ref, context, selectedAssets.value.toList())),
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.favorite),
),
ElevatedButton(
onPressed: () =>
handleProcessing(() => handleArchiveAssets(ref, context, selectedAssets.value.toList()), true),
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.archive),
),
],
),
),
],
);
}
}

View file

@ -0,0 +1,289 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:immich_mobile/utils/throttle.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/widgets/common/drag_sheet.dart';
import 'package:logging/logging.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class MapAssetGrid extends HookConsumerWidget {
final Stream<MapEvent> mapEventStream;
final Function(String)? onGridAssetChanged;
final Function(String)? onZoomToAsset;
final Function(bool, Set<Asset>)? onAssetsSelected;
final ValueNotifier<Set<Asset>> selectedAssets;
final ScrollController controller;
const MapAssetGrid({
required this.mapEventStream,
this.onGridAssetChanged,
this.onZoomToAsset,
this.onAssetsSelected,
required this.selectedAssets,
required this.controller,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final log = Logger("MapAssetGrid");
final assetsInBounds = useState<List<Asset>>([]);
final cachedRenderList = useRef<RenderList?>(null);
final lastRenderElementIndex = useRef<int?>(null);
final assetInSheet = useValueNotifier<String?>(null);
final gridScrollThrottler = useThrottler(interval: const Duration(milliseconds: 300));
// Add a cache for assets we've already loaded
final assetCache = useRef<Map<String, Asset>>({});
void handleMapEvents(MapEvent event) async {
if (event is MapAssetsInBoundsUpdated) {
final assetIds = event.assetRemoteIds;
final missingIds = <String>[];
final currentAssets = <Asset>[];
for (final id in assetIds) {
final asset = assetCache.value[id];
if (asset != null) {
currentAssets.add(asset);
} else {
missingIds.add(id);
}
}
// Only fetch missing assets
if (missingIds.isNotEmpty) {
final newAssets = await ref.read(dbProvider).assets.getAllByRemoteId(missingIds);
// Add new assets to cache and current list
for (final asset in newAssets) {
if (asset.remoteId != null) {
assetCache.value[asset.remoteId!] = asset;
currentAssets.add(asset);
}
}
}
assetsInBounds.value = currentAssets;
return;
}
}
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
// Hard-restrict to 4 assets / row in portrait mode
const assetsPerRow = 4;
void handleVisibleItems(Iterable<ItemPosition> positions) {
final orderedPos = positions.sortedByField((p) => p.index);
// Index of row where the items are mostly visible
const partialOffset = 0.20;
final item = orderedPos.firstWhereOrNull((p) => p.itemTrailingEdge > partialOffset);
// Guard no elements, reset state
// Also fail fast when the sheet is just opened and the user is yet to scroll (i.e leading = 0)
if (item == null || item.itemLeadingEdge == 0) {
lastRenderElementIndex.value = null;
return;
}
final renderElement = cachedRenderList.value?.elements.elementAtOrNull(item.index);
// Guard no render list or render element
if (renderElement == null) {
return;
}
// Reset index
lastRenderElementIndex.value == item.index;
// <RenderElement:offset:0>
// | 1 | 2 | 3 | 4 | 5 | 6 |
// <RenderElement:offset:6>
// | 7 | 8 | 9 |
// <RenderElement:offset:9>
// | 10 |
// Skip through the assets from the previous row
final rowOffset = renderElement.offset;
// Column offset = (total trailingEdge - trailingEdge crossed) / offset for each asset
final totalOffset = item.itemTrailingEdge - item.itemLeadingEdge;
final edgeOffset =
(totalOffset - partialOffset) /
// Round the total count to the next multiple of [assetsPerRow]
((renderElement.totalCount / assetsPerRow) * assetsPerRow).floor();
// trailing should never be above the totalOffset
final columnOffset = (totalOffset - math.min(item.itemTrailingEdge, totalOffset)) ~/ edgeOffset;
final assetOffset = rowOffset + columnOffset;
final selectedAsset = cachedRenderList.value?.allAssets?.elementAtOrNull(assetOffset)?.remoteId;
if (selectedAsset != null) {
onGridAssetChanged?.call(selectedAsset);
assetInSheet.value = selectedAsset;
}
}
return Card(
margin: EdgeInsets.zero,
child: Stack(
children: [
/// The Align and FractionallySizedBox are to prevent the Asset Grid from going behind the
/// _MapSheetDragRegion and thereby displaying content behind the top right and top left curves
Align(
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
// Place it just below the drag handle
heightFactor: 0.87,
child: assetsInBounds.value.isNotEmpty
? ref
.watch(assetsTimelineProvider(assetsInBounds.value))
.when(
data: (renderList) {
// Cache render list here to use it back during visibleItemsListener
cachedRenderList.value = renderList;
return ValueListenableBuilder(
valueListenable: selectedAssets,
builder: (_, value, __) => ImmichAssetGrid(
shrinkWrap: true,
renderList: renderList,
showDragScroll: false,
assetsPerRow: assetsPerRow,
showMultiSelectIndicator: false,
selectionActive: value.isNotEmpty,
listener: onAssetsSelected,
visibleItemsListener: (pos) => gridScrollThrottler.run(() => handleVisibleItems(pos)),
),
);
},
error: (error, stackTrace) {
log.warning("Cannot get assets in the current map bounds", error, stackTrace);
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
)
: const _MapNoAssetsInSheet(),
),
),
_MapSheetDragRegion(
controller: controller,
assetsInBoundCount: assetsInBounds.value.length,
assetInSheet: assetInSheet,
onZoomToAsset: onZoomToAsset,
),
],
),
);
}
}
class _MapNoAssetsInSheet extends StatelessWidget {
const _MapNoAssetsInSheet();
@override
Widget build(BuildContext context) {
const image = Image(height: 150, width: 150, image: AssetImage('assets/lighthouse.png'));
return Center(
child: ListView(
shrinkWrap: true,
children: [
context.isDarkTheme
? const InvertionFilter(
child: SaturationFilter(saturation: -1, child: BrightnessFilter(brightness: -5, child: image)),
)
: image,
const SizedBox(height: 20),
Center(
child: Text("map_zoom_to_see_photos".tr(), style: context.textTheme.displayLarge?.copyWith(fontSize: 18)),
),
],
),
);
}
}
class _MapSheetDragRegion extends StatelessWidget {
final ScrollController controller;
final int assetsInBoundCount;
final ValueNotifier<String?> assetInSheet;
final Function(String)? onZoomToAsset;
const _MapSheetDragRegion({
required this.controller,
required this.assetsInBoundCount,
required this.assetInSheet,
this.onZoomToAsset,
});
@override
Widget build(BuildContext context) {
final assetsInBoundsText = "map_assets_in_bounds".t(context: context, args: {'count': assetsInBoundCount});
return SingleChildScrollView(
controller: controller,
physics: const ClampingScrollPhysics(),
child: Card(
margin: EdgeInsets.zero,
shape: context.isMobile
? const RoundedRectangleBorder(
borderRadius: BorderRadius.only(topRight: Radius.circular(20), topLeft: Radius.circular(20)),
)
: const BeveledRectangleBorder(),
elevation: 0.0,
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 15),
const CustomDraggingHandle(),
const SizedBox(height: 15),
Center(
child: Text(
assetsInBoundsText,
style: TextStyle(
fontSize: 20,
color: context.textTheme.displayLarge?.color?.withValues(alpha: 0.75),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 8),
],
),
ValueListenableBuilder(
valueListenable: assetInSheet,
builder: (_, value, __) => Visibility(
visible: value != null,
child: Positioned(
right: 18,
top: 24,
child: IconButton(
icon: Icon(Icons.map_outlined, color: context.textTheme.displayLarge?.color),
iconSize: 24,
tooltip: 'zoom_to_bounds'.tr(),
onPressed: () => onZoomToAsset?.call(value!),
),
),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
class MapBottomSheet extends HookConsumerWidget {
final Stream<MapEvent> mapEventStream;
final Function(String)? onGridAssetChanged;
final Function(String)? onZoomToAsset;
final Function()? onZoomToLocation;
final Function(bool, Set<Asset>)? onAssetsSelected;
final ValueNotifier<Set<Asset>> selectedAssets;
const MapBottomSheet({
required this.mapEventStream,
this.onGridAssetChanged,
this.onZoomToAsset,
this.onAssetsSelected,
this.onZoomToLocation,
required this.selectedAssets,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
const sheetMinExtent = 0.1;
final sheetController = useDraggableScrollController();
final bottomSheetOffset = useValueNotifier(sheetMinExtent);
final isBottomSheetOpened = useRef(false);
void handleMapEvents(MapEvent event) async {
if (event is MapCloseBottomSheet) {
await sheetController.animateTo(
0.1,
duration: const Duration(milliseconds: 200),
curve: Curves.linearToEaseOut,
);
}
}
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
bool onScrollNotification(DraggableScrollableNotification notification) {
isBottomSheetOpened.value = notification.extent > (notification.maxExtent * 0.9);
bottomSheetOffset.value = notification.extent;
// do not bubble
return true;
}
return Stack(
children: [
NotificationListener<DraggableScrollableNotification>(
onNotification: onScrollNotification,
child: DraggableScrollableSheet(
controller: sheetController,
minChildSize: sheetMinExtent,
maxChildSize: 0.8,
initialChildSize: sheetMinExtent,
snap: true,
snapSizes: [sheetMinExtent, 0.5, 0.8],
shouldCloseOnMinExtent: false,
builder: (ctx, scrollController) => MapAssetGrid(
controller: scrollController,
mapEventStream: mapEventStream,
selectedAssets: selectedAssets,
onAssetsSelected: onAssetsSelected,
// Do not bother with the event if the bottom sheet is not user scrolled
onGridAssetChanged: (assetId) => isBottomSheetOpened.value ? onGridAssetChanged?.call(assetId) : null,
onZoomToAsset: onZoomToAsset,
),
),
),
ValueListenableBuilder(
valueListenable: bottomSheetOffset,
builder: (context, value, child) {
return Positioned(
right: 0,
bottom: context.height * (value + 0.02),
child: AnimatedOpacity(
opacity: value < 0.8 ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.my_location),
),
),
);
},
),
],
);
}
}

View file

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class MapSettingsListTile extends StatelessWidget {
final String title;
final bool selected;
final Function(bool) onChanged;
const MapSettingsListTile({super.key, required this.title, required this.selected, required this.onChanged});
@override
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
activeThumbColor: context.primaryColor,
title: Text(title, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5)),
value: selected,
onChanged: onChanged,
);
}
}

View file

@ -0,0 +1,59 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class MapTimeDropDown extends StatelessWidget {
final int relativeTime;
final Function(int) onTimeChange;
const MapTimeDropDown({super.key, required this.relativeTime, required this.onTimeChange});
@override
Widget build(BuildContext context) {
final now = DateTime.now();
return Padding(
padding: const EdgeInsets.only(left: 16, right: 28.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"date_range".t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
Flexible(
child: DropdownMenu(
enableSearch: false,
enableFilter: false,
initialSelection: relativeTime,
onSelected: (value) => onTimeChange(value!),
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "all".t(context: context)),
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".t(context: context)),
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
DropdownMenuEntry(
value: 30,
label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"}),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_year".t(context: context),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_years".t(args: {'years': "3"}),
),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapThemePicker extends StatelessWidget {
final ThemeMode themeMode;
final Function(ThemeMode) onThemeChange;
const MapThemePicker({super.key, required this.themeMode, required this.onThemeChange});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Center(
child: Text(
"map_settings_theme_settings".t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BorderedMapThumbnail(
name: "Light",
mode: ThemeMode.light,
shouldHighlight: themeMode == ThemeMode.light,
onThemeChange: onThemeChange,
),
_BorderedMapThumbnail(
name: "Dark",
mode: ThemeMode.dark,
shouldHighlight: themeMode == ThemeMode.dark,
onThemeChange: onThemeChange,
),
_BorderedMapThumbnail(
name: "System",
mode: ThemeMode.system,
shouldHighlight: themeMode == ThemeMode.system,
onThemeChange: onThemeChange,
),
],
),
],
);
}
}
class _BorderedMapThumbnail extends StatelessWidget {
final ThemeMode mode;
final String name;
final bool shouldHighlight;
final Function(ThemeMode) onThemeChange;
const _BorderedMapThumbnail({
required this.mode,
required this.name,
required this.shouldHighlight,
required this.onThemeChange,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(width: 4, color: shouldHighlight ? context.colorScheme.onSurface : Colors.transparent),
),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: MapThumbnail(
zoom: 2,
centre: const LatLng(47, 5),
onTap: (_, __) => onThemeChange(mode),
themeMode: mode,
showAttribution: false,
),
),
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
name,
style: context.textTheme.bodyMedium?.copyWith(fontWeight: shouldHighlight ? FontWeight.bold : null),
),
),
],
);
}
}

View file

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class MapSettingsSheet extends HookConsumerWidget {
const MapSettingsSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapState = ref.watch(mapStateNotifierProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
elevation: 0.0,
shadowColor: Colors.transparent,
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
MapThemePicker(
themeMode: mapState.themeMode,
onThemeChange: (mode) => ref.read(mapStateNotifierProvider.notifier).switchTheme(mode),
),
const Divider(height: 30, thickness: 2),
MapSettingsListTile(
title: "map_settings_only_show_favorites",
selected: mapState.showFavoriteOnly,
onChanged: (favoriteOnly) =>
ref.read(mapStateNotifierProvider.notifier).switchFavoriteOnly(favoriteOnly),
),
MapSettingsListTile(
title: "map_settings_include_show_archived",
selected: mapState.includeArchived,
onChanged: (includeArchive) =>
ref.read(mapStateNotifierProvider.notifier).switchIncludeArchived(includeArchive),
),
MapSettingsListTile(
title: "map_settings_include_show_partners",
selected: mapState.withPartners,
onChanged: (withPartners) =>
ref.read(mapStateNotifierProvider.notifier).switchWithPartners(withPartners),
),
MapTimeDropDown(
relativeTime: mapState.relativeTime,
onTimeChange: (time) => ref.read(mapStateNotifierProvider.notifier).setRelativeTime(time),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}

View file

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/theme/theme_data.dart';
/// Overrides the theme below the widget tree to use the theme data based on the
/// map settings instead of the one from the app settings
class MapThemeOverride extends StatefulHookConsumerWidget {
final ThemeMode? themeMode;
final Widget Function(AsyncValue<String> style) mapBuilder;
const MapThemeOverride({required this.mapBuilder, this.themeMode, super.key});
@override
ConsumerState createState() => _MapThemeOverrideState();
}
class _MapThemeOverrideState extends ConsumerState<MapThemeOverride> with WidgetsBindingObserver {
late ThemeMode _theme;
bool _isDarkTheme = false;
bool get _isSystemDark => WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark;
bool checkDarkTheme() {
return _theme == ThemeMode.dark || _theme == ThemeMode.system && _isSystemDark;
}
@override
void initState() {
super.initState();
_theme = widget.themeMode ?? ref.read(mapStateNotifierProvider.select((v) => v.themeMode));
setState(() {
_isDarkTheme = checkDarkTheme();
});
if (_theme == ThemeMode.system) {
WidgetsBinding.instance.addObserver(this);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_theme != ThemeMode.system) {
WidgetsBinding.instance.removeObserver(this);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangePlatformBrightness() {
super.didChangePlatformBrightness();
if (_theme == ThemeMode.system) {
setState(() => _isDarkTheme = _isSystemDark);
}
}
@override
Widget build(BuildContext context) {
_theme = widget.themeMode ?? ref.watch(mapStateNotifierProvider.select((v) => v.themeMode));
var appTheme = ref.watch(immichThemeProvider);
final locale = ref.watch(localeProvider);
useValueChanged<ThemeMode, void>(_theme, (_, __) {
if (_theme == ThemeMode.system) {
WidgetsBinding.instance.addObserver(this);
} else {
WidgetsBinding.instance.removeObserver(this);
}
setState(() {
_isDarkTheme = checkDarkTheme();
});
});
return Theme(
data: _isDarkTheme
? getThemeData(colorScheme: appTheme.dark, locale: locale)
: getThemeData(colorScheme: appTheme.light, locale: locale),
child: widget.mapBuilder.call(
ref.watch(mapStateNotifierProvider.select((v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched)),
),
);
}
}

View file

@ -0,0 +1,129 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
/// A non-interactive thumbnail of a map in the given coordinates with optional markers
///
/// User can provide either a [assetMarkerRemoteId] to display the asset's thumbnail or set
/// [showMarkerPin] to true which would display a marker pin instead. If both are provided,
/// [assetMarkerRemoteId] will take precedence
class MapThumbnail extends HookConsumerWidget {
final Function(Point<double>, LatLng)? onTap;
final LatLng centre;
final String? assetMarkerRemoteId;
final String? assetThumbhash;
final bool showMarkerPin;
final double zoom;
final double height;
final double width;
final ThemeMode? themeMode;
final bool showAttribution;
final MapCreatedCallback? onCreated;
const MapThumbnail({
super.key,
required this.centre,
this.height = 100,
this.width = 100,
this.onTap,
this.zoom = 8,
this.assetMarkerRemoteId,
this.assetThumbhash,
this.showMarkerPin = false,
this.themeMode,
this.showAttribution = true,
this.onCreated,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
final controller = useRef<MapLibreMapController?>(null);
final styleLoaded = useState(false);
final position = useValueNotifier<Point<num>?>(null);
Future<void> onMapCreated(MapLibreMapController mapController) async {
controller.value = mapController;
styleLoaded.value = false;
if (assetMarkerRemoteId != null) {
// The iOS impl returns wrong toScreenLocation without the delay
Future.delayed(
const Duration(milliseconds: 100),
() async => position.value = await mapController.toScreenLocation(centre),
);
}
onCreated?.call(mapController);
}
Future<void> onStyleLoaded() async {
try {
if (showMarkerPin && controller.value != null) {
await controller.value?.addMarkerAtLatLng(centre);
}
} finally {
// Calling methods on the controller after it is disposed will throw an error
// We do not have a way to check if the controller is disposed for now
// https://github.com/maplibre/flutter-maplibre-gl/issues/192
}
styleLoaded.value = true;
}
return MapThemeOverride(
themeMode: themeMode,
mapBuilder: (style) => AnimatedContainer(
duration: Durations.medium2,
curve: Curves.easeOut,
foregroundDecoration: BoxDecoration(
color: context.colorScheme.inverseSurface.withAlpha(styleLoaded.value ? 0 : 200),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
height: height,
width: width,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: Stack(
alignment: Alignment.center,
children: [
style.widgetWhen(
onData: (style) => MapLibreMap(
initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom),
styleString: style,
onMapCreated: onMapCreated,
onStyleLoadedCallback: onStyleLoaded,
onMapClick: onTap,
doubleClickZoomEnabled: false,
dragEnabled: false,
zoomGesturesEnabled: false,
tiltGesturesEnabled: false,
scrollGesturesEnabled: false,
rotateGesturesEnabled: false,
myLocationEnabled: false,
attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null,
),
),
ValueListenableBuilder(
valueListenable: position,
builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null
? PositionedAssetMarkerIcon(
size: height / 2,
point: value,
assetRemoteId: assetMarkerRemoteId!,
assetThumbhash: assetThumbhash!,
)
: const SizedBox.shrink(),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,148 @@
import 'dart:io';
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point;
final String assetRemoteId;
final String assetThumbhash;
final double size;
final int durationInMilliseconds;
final Function()? onTap;
const PositionedAssetMarkerIcon({
required this.point,
required this.assetRemoteId,
required this.assetThumbhash,
this.size = 100,
this.durationInMilliseconds = 100,
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
final ratio = Platform.isIOS ? 1.0 : context.devicePixelRatio;
return AnimatedPositioned(
left: point.x / ratio - size / 2,
top: point.y / ratio - size,
duration: Duration(milliseconds: durationInMilliseconds),
child: GestureDetector(
onTap: () => onTap?.call(),
child: SizedBox.square(
dimension: size,
child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
),
),
);
}
}
class _AssetMarkerIcon extends StatelessWidget {
const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key});
final String id;
final String thumbhash;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
final cacheKey = getThumbnailCacheKeyForRemoteId(id, thumbhash);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
Positioned(
bottom: 0,
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: context.colorScheme.onSurface,
secondaryColor: context.colorScheme.surface,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
child: SizedBox(height: constraints.maxHeight * 0.14, width: constraints.maxWidth * 0.14),
),
),
Positioned(
top: constraints.maxHeight * 0.07,
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: context.colorScheme.onSurface,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
cacheKey: cacheKey,
headers: ApiService.getRequestHeaders(),
errorListener: (_) => const Icon(Icons.image_not_supported_outlined),
),
),
),
),
],
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
const _PinPainter({
required this.primaryColor,
required this.secondaryColor,
required this.primaryRadius,
required this.secondaryRadius,
});
@override
void paint(Canvas canvas, Size size) {
Paint primaryBrush = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
Paint secondaryBrush = Paint()
..color = secondaryColor
..style = PaintingStyle.fill;
Paint lineBrush = Paint()
..color = primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush);
canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush);
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
// The line is to make the above triangluar path more prominent since it has a slight curve
canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush);
}
Path getTrianglePath(double x, double y) {
final firstEndPoint = Offset(x / 2, y);
final controlPoint = Offset(x / 2, y * 0.3);
final secondEndPoint = Offset(x, 0);
return Path()
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy)
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy)
..lineTo(0, 0);
}
@override
bool shouldRepaint(_PinPainter old) {
return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor;
}
}