mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
parent
7520ffd6c3
commit
5806a3ce25
203 changed files with 318 additions and 318 deletions
159
mobile/lib/widgets/map/map_app_bar.dart
Normal file
159
mobile/lib/widgets/map/map_app_bar.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
280
mobile/lib/widgets/map/map_asset_grid.dart
Normal file
280
mobile/lib/widgets/map/map_asset_grid.dart
Normal 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!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
mobile/lib/widgets/map/map_bottom_sheet.dart
Normal file
97
mobile/lib/widgets/map/map_bottom_sheet.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
109
mobile/lib/widgets/map/map_settings/map_theme_picker.dart
Normal file
109
mobile/lib/widgets/map/map_settings/map_theme_picker.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
68
mobile/lib/widgets/map/map_settings_sheet.dart
Normal file
68
mobile/lib/widgets/map/map_settings_sheet.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
mobile/lib/widgets/map/map_theme_override.dart
Normal file
96
mobile/lib/widgets/map/map_theme_override.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
mobile/lib/widgets/map/map_thumbnail.dart
Normal file
110
mobile/lib/widgets/map/map_thumbnail.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
183
mobile/lib/widgets/map/positioned_asset_marker_icon.dart
Normal file
183
mobile/lib/widgets/map/positioned_asset_marker_icon.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue