Source Code added
This commit is contained in:
parent
800376eafd
commit
9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions
128
mobile/lib/widgets/map/map_app_bar.dart
Normal file
128
mobile/lib/widgets/map/map_app_bar.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
289
mobile/lib/widgets/map/map_asset_grid.dart
Normal file
289
mobile/lib/widgets/map/map_asset_grid.dart
Normal 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!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
98
mobile/lib/widgets/map/map_bottom_sheet.dart
Normal file
98
mobile/lib/widgets/map/map_bottom_sheet.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
mobile/lib/widgets/map/map_settings/map_theme_picker.dart
Normal file
97
mobile/lib/widgets/map/map_settings/map_theme_picker.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
61
mobile/lib/widgets/map/map_settings_sheet.dart
Normal file
61
mobile/lib/widgets/map/map_settings_sheet.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
92
mobile/lib/widgets/map/map_theme_override.dart
Normal file
92
mobile/lib/widgets/map/map_theme_override.dart
Normal 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
129
mobile/lib/widgets/map/map_thumbnail.dart
Normal file
129
mobile/lib/widgets/map/map_thumbnail.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
148
mobile/lib/widgets/map/positioned_asset_marker_icon.dart
Normal file
148
mobile/lib/widgets/map/positioned_asset_marker_icon.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue