refactor(mobile): widgets (#9291)

* refactor(mobile): widgets

* update
This commit is contained in:
Alex 2024-05-06 23:04:21 -05:00 committed by GitHub
parent 7520ffd6c3
commit 5806a3ce25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
203 changed files with 318 additions and 318 deletions

View file

@ -0,0 +1,159 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/widgets/map/map_settings_sheet.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.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: MediaQuery.paddingOf(context).top + 25),
child: ValueListenableBuilder(
valueListenable: selectedAssets,
builder: (ctx, value, child) => value.isNotEmpty
? _SelectionRow(selectedAssets: selectedAssets)
: _NonSelectionRow(),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(100);
}
class _NonSelectionRow extends StatelessWidget {
@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.popRoute(),
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,280 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/render_list.provider.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/models/map/map_event.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/widgets/common/drag_sheet.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:immich_mobile/utils/throttle.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));
void handleMapEvents(MapEvent event) async {
if (event is MapAssetsInBoundsUpdated) {
assetsInBounds.value = await ref
.read(dbProvider)
.assets
.getAllByRemoteId(event.assetRemoteIds);
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.80,
child: assetsInBounds.value.isNotEmpty
? ref.watch(renderListProvider(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(),
)
: _MapNoAssetsInSheet(),
),
),
_MapSheetDragRegion(
controller: controller,
assetsInBoundCount: assetsInBounds.value.length,
assetInSheet: assetInSheet,
onZoomToAsset: onZoomToAsset,
),
],
),
);
}
}
class _MapNoAssetsInSheet extends StatelessWidget {
@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 = assetsInBoundCount > 0
? "map_assets_in_bounds".tr(args: [assetsInBoundCount.toString()])
: "map_no_assets_in_bounds".tr();
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),
Text(assetsInBoundsText, style: context.textTheme.bodyLarge),
const Divider(height: 35),
],
),
ValueListenableBuilder(
valueListenable: assetInSheet,
builder: (_, value, __) => Visibility(
visible: value != null,
child: Positioned(
right: 15,
top: 15,
child: IconButton(
icon: Icon(
Icons.map_outlined,
color: context.textTheme.displayLarge?.color,
),
iconSize: 20,
tooltip: 'Zoom to bounds',
onPressed: () => onZoomToAsset?.call(value!),
),
),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/draggable_scroll_controller.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) {
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.5,
initialChildSize: sheetMinExtent,
snap: true,
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: (ctx, value, child) => Positioned(
right: 0,
bottom: context.height * (value + 0.02),
child: child!,
),
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
child: const Icon(Icons.my_location),
),
),
],
);
}
}

View file

@ -0,0 +1,31 @@
import 'package:easy_localization/easy_localization.dart';
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(
activeColor: context.primaryColor,
title: Text(
title,
style:
context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
).tr(),
value: selected,
onChanged: onChanged,
);
}
}

View file

@ -0,0 +1,92 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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 Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
"map_settings_only_relative_range".tr(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
LayoutBuilder(
builder: (_, constraints) => DropdownMenu(
width: constraints.maxWidth * 0.9,
enableSearch: false,
enableFilter: false,
initialSelection: relativeTime,
onSelected: (value) => onTimeChange(value!),
dropdownMenuEntries: [
DropdownMenuEntry(
value: 0,
label: "map_settings_date_range_option_all".tr(),
),
DropdownMenuEntry(
value: 1,
label: "map_settings_date_range_option_day".tr(),
),
DropdownMenuEntry(
value: 7,
label: "map_settings_date_range_option_days".tr(
args: ["7"],
),
),
DropdownMenuEntry(
value: 30,
label: "map_settings_date_range_option_days".tr(
args: ["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".tr(),
),
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".tr(args: ["3"]),
),
],
),
),
],
);
}
}

View file

@ -0,0 +1,109 @@
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/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",
style: context.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
),
),
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,68 @@
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,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/utils/immich_app_theme.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 MapThemeOveride extends StatefulHookConsumerWidget {
final ThemeMode? themeMode;
final Widget Function(AsyncValue<String> style) mapBuilder;
const MapThemeOveride({required this.mapBuilder, this.themeMode, super.key});
@override
ConsumerState createState() => _MapThemeOverideState();
}
class _MapThemeOverideState extends ConsumerState<MapThemeOveride>
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));
useValueChanged<ThemeMode, void>(_theme, (_, __) {
if (_theme == ThemeMode.system) {
WidgetsBinding.instance.addObserver(this);
} else {
WidgetsBinding.instance.removeObserver(this);
}
setState(() {
_isDarkTheme = checkDarkTheme();
});
});
return Theme(
data: _isDarkTheme ? immichDarkTheme : immichLightTheme,
child: widget.mapBuilder.call(
ref.watch(
mapStateNotifierProvider.select(
(v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched,
),
),
),
);
}
}

View file

@ -0,0 +1,110 @@
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/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 bool showMarkerPin;
final double zoom;
final double height;
final double width;
final ThemeMode? themeMode;
final bool showAttribution;
const MapThumbnail({
super.key,
required this.centre,
this.height = 100,
this.width = 100,
this.onTap,
this.zoom = 8,
this.assetMarkerRemoteId,
this.showMarkerPin = false,
this.themeMode,
this.showAttribution = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
final controller = useRef<MaplibreMapController?>(null);
final position = useValueNotifier<Point<num>?>(null);
Future<void> onMapCreated(MaplibreMapController mapController) async {
controller.value = mapController;
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),
);
}
}
Future<void> onStyleLoaded() async {
if (showMarkerPin && controller.value != null) {
await controller.value?.addMarkerAtLatLng(centre);
}
}
return MapThemeOveride(
themeMode: themeMode,
mapBuilder: (style) => SizedBox(
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
? PositionedAssetMarkerIcon(
size: height / 2,
point: value,
assetRemoteId: assetMarkerRemoteId!,
)
: const SizedBox.shrink(),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,183 @@
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/entities/store.entity.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point;
final String assetRemoteId;
final double size;
final int durationInMilliseconds;
final Function()? onTap;
const PositionedAssetMarkerIcon({
required this.point,
required this.assetRemoteId,
this.size = 100,
this.durationInMilliseconds = 100,
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
final ratio = Platform.isIOS ? 1.0 : MediaQuery.devicePixelRatioOf(context);
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,
key: Key(assetRemoteId),
),
),
),
);
}
}
class _AssetMarkerIcon extends StatelessWidget {
const _AssetMarkerIcon({
required this.id,
super.key,
});
final String id;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
final cacheKey = getThumbnailCacheKeyForRemoteId(id);
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: {
"x-immich-user-token": Store.get(StoreKey.accessToken),
},
errorListener: (_) =>
const Icon(Icons.image_not_supported_outlined),
),
),
),
),
],
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
_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;
}
}