Show family members on the map

This commit is contained in:
Eugene Burmakin 2025-09-29 21:31:24 +02:00
parent fa3d926a92
commit f6b32371ec
14 changed files with 1075 additions and 32 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
class Api::V1::FamiliesController < ApiController
before_action :ensure_family_feature_enabled!
before_action :ensure_user_in_family!
def locations
family_locations = Families::Locations.new(current_api_user).call
render json: {
locations: family_locations,
updated_at: Time.current.iso8601,
sharing_enabled: current_api_user.family_sharing_enabled?
}
end
private
def ensure_family_feature_enabled!
unless DawarichSettings.family_feature_enabled?
render json: { error: 'Family feature is not enabled' }, status: :forbidden
end
end
def ensure_user_in_family!
unless current_api_user.in_family?
render json: { error: 'User is not part of a family' }, status: :forbidden
end
end
end

View file

@ -3,7 +3,7 @@
class FamiliesController < ApplicationController
before_action :authenticate_user!
before_action :ensure_family_feature_enabled!
before_action :set_family, only: %i[show edit update destroy leave]
before_action :set_family, only: %i[show edit update destroy leave update_location_sharing]
def index
redirect_to family_path(current_user.family) if current_user.in_family?
@ -96,8 +96,60 @@ class FamiliesController < ApplicationController
alert: 'You cannot leave the family while you are the owner and there are other members. Remove all members first or transfer ownership.'
end
def update_location_sharing
# No authorization needed - users can control their own location sharing
enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
duration = params[:duration]
if current_user.update_family_location_sharing!(enabled, duration: duration)
response_data = {
success: true,
enabled: enabled,
duration: current_user.family_sharing_duration,
message: build_sharing_message(enabled, duration)
}
# Add expiration info if sharing is time-limited
if enabled && current_user.family_sharing_expires_at.present?
response_data[:expires_at] = current_user.family_sharing_expires_at.iso8601
response_data[:expires_at_formatted] = current_user.family_sharing_expires_at.strftime('%b %d at %I:%M %p')
end
render json: response_data
else
render json: {
success: false,
message: 'Failed to update location sharing setting'
}, status: :unprocessable_content
end
rescue => e
render json: {
success: false,
message: 'An error occurred while updating location sharing'
}, status: :internal_server_error
end
private
def build_sharing_message(enabled, duration)
return 'Location sharing disabled' unless enabled
case duration
when '1h'
'Location sharing enabled for 1 hour'
when '6h'
'Location sharing enabled for 6 hours'
when '12h'
'Location sharing enabled for 12 hours'
when '24h'
'Location sharing enabled for 24 hours'
when 'permanent', nil
'Location sharing enabled'
else
duration.to_i > 0 ? "Location sharing enabled for #{duration.to_i} hours" : 'Location sharing enabled'
end
end
def ensure_family_feature_enabled!
unless DawarichSettings.family_feature_enabled?
redirect_to root_path, alert: 'Family feature is not available'

View file

@ -13,6 +13,7 @@ class MapController < ApplicationController
@years = years_range
@points_number = points_count
@features = DawarichSettings.features
@family_member_locations = family_member_locations
end
private
@ -93,4 +94,8 @@ class MapController < ApplicationController
def points_from_user
current_user.points.without_raw_data.order(timestamp: :asc)
end
def family_member_locations
Families::Locations.new(current_user).call
end
end

View file

@ -0,0 +1,320 @@
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import { showFlashMessage } from "../maps/helpers";
export default class extends Controller {
static targets = [];
static values = {
familyMemberLocations: Array,
features: Object,
userTheme: String
}
connect() {
console.log("Family members controller connected");
// Wait for maps controller to be ready
this.waitForMap();
}
disconnect() {
this.cleanup();
console.log("Family members controller disconnected");
}
waitForMap() {
// Find the maps controller element
const mapElement = document.querySelector('[data-controller*="maps"]');
if (!mapElement) {
console.warn('Maps controller element not found');
return;
}
// Wait for the maps controller to be initialized
const checkMapReady = () => {
if (window.mapsController && window.mapsController.map) {
this.initializeFamilyFeatures();
} else {
setTimeout(checkMapReady, 100);
}
};
checkMapReady();
}
initializeFamilyFeatures() {
this.map = window.mapsController.map;
if (!this.map) {
console.warn('Map not available for family members controller');
return;
}
// Initialize family member markers layer
this.familyMarkersLayer = L.layerGroup();
this.createFamilyMarkers();
// Add to layer control if available
this.addToLayerControl();
// Add markers to map if layer control integration doesn't work initially
if (this.familyMarkersLayer.getLayers().length > 0) {
this.familyMarkersLayer.addTo(this.map);
}
// Listen for family data updates
this.setupEventListeners();
}
createFamilyMarkers() {
// Clear existing family markers
if (this.familyMarkersLayer) {
this.familyMarkersLayer.clearLayers();
}
// Only proceed if family feature is enabled and we have family member locations
if (!this.featuresValue.family ||
!this.familyMemberLocationsValue ||
this.familyMemberLocationsValue.length === 0) {
return;
}
this.familyMemberLocationsValue.forEach((location) => {
if (!location || !location.latitude || !location.longitude) {
return;
}
// Get the first letter of the email or use '?' as fallback
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
// Create a distinct marker for family members with email initial
const familyMarker = L.marker([location.latitude, location.longitude], {
icon: L.divIcon({
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
className: 'family-member-marker'
})
});
// Format timestamp for display
const lastSeen = new Date(location.updated_at).toLocaleString();
// Create popup content with theme-aware styling
const popupContent = this.createPopupContent(location, lastSeen);
familyMarker.bindPopup(popupContent);
this.familyMarkersLayer.addLayer(familyMarker);
});
}
createPopupContent(location, lastSeen) {
const isDark = this.userThemeValue === 'dark';
const bgColor = isDark ? '#1f2937' : '#ffffff';
const textColor = isDark ? '#f9fafb' : '#111827';
const mutedColor = isDark ? '#9ca3af' : '#6b7280';
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
return `
<div class="family-member-popup" style="background-color: ${bgColor}; color: ${textColor}; padding: 8px; border-radius: 6px; min-width: 200px;">
<h3 style="margin: 0 0 8px 0; color: #10B981; font-size: 14px; font-weight: bold; display: flex; align-items: center; gap: 6px;">
<span style="background-color: #10B981; color: white; border-radius: 50%; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold;">${emailInitial}</span>
Family Member
</h3>
<p style="margin: 0 0 4px 0; font-size: 12px;">
<strong>Email:</strong> ${location.email || 'Unknown'}
</p>
<p style="margin: 0 0 4px 0; font-size: 12px;">
<strong>Location:</strong> ${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}
</p>
<p style="margin: 0; font-size: 12px; color: ${mutedColor};">
<strong>Last seen:</strong> ${lastSeen}
</p>
</div>
`;
}
addToLayerControl() {
// Add family markers layer to the maps controller's layer control
if (window.mapsController && window.mapsController.layerControl && this.familyMarkersLayer) {
// We need to recreate the layer control to include our new layer
this.updateMapsControllerLayerControl();
}
}
updateMapsControllerLayerControl() {
const mapsController = window.mapsController;
if (!mapsController || typeof mapsController.updateLayerControl !== 'function') return;
// Use the maps controller's helper method to update layer control
mapsController.updateLayerControl({
"Family Members": this.familyMarkersLayer
});
}
setupEventListeners() {
// Listen for family data updates (for real-time updates in the future)
document.addEventListener('family:locations:updated', (event) => {
this.familyMemberLocationsValue = event.detail.locations;
this.createFamilyMarkers();
});
// Listen for theme changes
document.addEventListener('theme:changed', (event) => {
this.userThemeValue = event.detail.theme;
// Recreate popups with new theme
this.createFamilyMarkers();
});
// Listen for layer control events
this.setupLayerControlEvents();
}
setupLayerControlEvents() {
if (!this.map) return;
// Listen for when the Family Members layer is added
this.map.on('overlayadd', (event) => {
if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) {
console.log('Family Members layer enabled - refreshing locations');
this.refreshFamilyLocations();
// Set up periodic refresh while layer is active
this.startPeriodicRefresh();
}
});
// Listen for when the Family Members layer is removed
this.map.on('overlayremove', (event) => {
if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) {
// Stop periodic refresh when layer is disabled
this.stopPeriodicRefresh();
}
});
}
startPeriodicRefresh() {
// Clear any existing refresh interval
this.stopPeriodicRefresh();
// Refresh family locations every 30 seconds while layer is active
this.refreshInterval = setInterval(() => {
if (this.map && this.map.hasLayer(this.familyMarkersLayer)) {
this.refreshFamilyLocations();
} else {
// Layer is no longer active, stop refreshing
this.stopPeriodicRefresh();
}
}, 30000); // 30 seconds
}
stopPeriodicRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
// Method to manually update family member locations (for API calls)
updateFamilyLocations(locations) {
this.familyMemberLocationsValue = locations;
this.createFamilyMarkers();
// Dispatch event for other controllers that might be interested
document.dispatchEvent(new CustomEvent('family:locations:updated', {
detail: { locations: locations }
}));
}
// Method to refresh family locations from API
async refreshFamilyLocations() {
if (!window.mapsController?.apiKey) {
console.warn('API key not available for family locations refresh');
return;
}
try {
const response = await fetch(`/api/v1/families/locations?api_key=${window.mapsController.apiKey}`, {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
});
if (!response.ok) {
if (response.status === 403) {
console.warn('Family feature not enabled or user not in family');
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.updateFamilyLocations(data.locations || []);
// Show user feedback if this was a manual refresh
if (this.showUserFeedback) {
const count = data.locations?.length || 0;
this.showFlashMessageToUser('notice', `Family locations updated (${count} members)`);
this.showUserFeedback = false; // Reset flag
}
} catch (error) {
console.error('Error refreshing family locations:', error);
// Show error to user if this was a manual refresh
if (this.showUserFeedback) {
this.showFlashMessageToUser('error', 'Failed to refresh family locations');
this.showUserFeedback = false; // Reset flag
}
}
}
// Helper method to show flash messages using the imported helper
showFlashMessageToUser(type, message) {
showFlashMessage(type, message);
}
// Method for manual refresh with user feedback
async manualRefreshFamilyLocations() {
this.showUserFeedback = true; // Enable user feedback for this refresh
await this.refreshFamilyLocations();
}
cleanup() {
// Stop periodic refresh
this.stopPeriodicRefresh();
// Remove family markers layer from map if it exists
if (this.familyMarkersLayer && this.map && this.map.hasLayer(this.familyMarkersLayer)) {
this.map.removeLayer(this.familyMarkersLayer);
}
// Remove map event listeners
if (this.map) {
this.map.off('overlayadd');
this.map.off('overlayremove');
}
// Remove document event listeners
document.removeEventListener('family:locations:updated', this.handleLocationUpdates);
document.removeEventListener('theme:changed', this.handleThemeChange);
}
// Expose layer for external access
getFamilyMarkersLayer() {
return this.familyMarkersLayer;
}
// Check if family features are enabled
isFamilyFeatureEnabled() {
return this.featuresValue.family === true;
}
// Get family marker count
getFamilyMemberCount() {
return this.familyMemberLocationsValue ? this.familyMemberLocationsValue.length : 0;
}
}

View file

@ -0,0 +1,48 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["indicator"];
static values = {
enabled: Boolean
};
connect() {
console.log("Family navbar indicator controller connected");
this.updateIndicator();
// Listen for location sharing updates
document.addEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this));
document.addEventListener('location-sharing:expired', this.handleSharingExpired.bind(this));
}
disconnect() {
document.removeEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this));
document.removeEventListener('location-sharing:expired', this.handleSharingExpired.bind(this));
}
handleSharingUpdate(event) {
// Only update if this is the current user's sharing change
// (we're only showing the current user's status in navbar)
this.enabledValue = event.detail.enabled;
this.updateIndicator();
}
handleSharingExpired(event) {
this.enabledValue = false;
this.updateIndicator();
}
updateIndicator() {
if (!this.hasIndicatorTarget) return;
if (this.enabledValue) {
// Green pulsing indicator for enabled
this.indicatorTarget.className = "w-2 h-2 bg-green-500 rounded-full animate-pulse";
this.indicatorTarget.title = "Location sharing enabled";
} else {
// Gray indicator for disabled
this.indicatorTarget.className = "w-2 h-2 bg-gray-400 rounded-full";
this.indicatorTarget.title = "Location sharing disabled";
}
}
}

View file

@ -0,0 +1,276 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["checkbox", "durationContainer", "durationSelect", "expirationInfo"];
static values = {
memberId: Number,
enabled: Boolean,
familyId: Number,
duration: String,
expiresAt: String
};
connect() {
console.log("Location sharing toggle controller connected");
this.updateToggleState();
this.setupExpirationTimer();
}
disconnect() {
this.clearExpirationTimer();
}
toggle() {
const newState = !this.enabledValue;
const duration = this.hasDurationSelectTarget ? this.durationSelectTarget.value : 'permanent';
// Optimistically update UI
this.enabledValue = newState;
this.updateToggleState();
// Send the update to server
this.updateLocationSharing(newState, duration);
}
changeDuration() {
if (!this.enabledValue) return; // Only allow duration changes when sharing is enabled
const duration = this.durationSelectTarget.value;
this.durationValue = duration;
// Update sharing with new duration
this.updateLocationSharing(true, duration);
}
updateToggleState() {
const isEnabled = this.enabledValue;
// Update checkbox (DaisyUI toggle)
this.checkboxTarget.checked = isEnabled;
// Show/hide duration container
if (this.hasDurationContainerTarget) {
if (isEnabled) {
this.durationContainerTarget.classList.remove('hidden');
} else {
this.durationContainerTarget.classList.add('hidden');
}
}
}
async updateLocationSharing(enabled, duration = 'permanent') {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const response = await fetch(`/families/${this.familyIdValue}/update_location_sharing`, {
method: 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
enabled: enabled,
duration: duration
})
});
const data = await response.json();
if (data.success) {
// Update local values from server response
this.durationValue = data.duration;
this.expiresAtValue = data.expires_at;
// Update duration select if it exists
if (this.hasDurationSelectTarget) {
this.durationSelectTarget.value = data.duration;
}
// Update expiration info
this.updateExpirationInfo(data.expires_at_formatted);
// Show success message
this.showFlashMessage('success', data.message);
// Setup/clear expiration timer
this.setupExpirationTimer();
// Trigger custom event for other controllers to listen to
document.dispatchEvent(new CustomEvent('location-sharing:updated', {
detail: {
userId: this.memberIdValue,
enabled: enabled,
duration: data.duration,
expiresAt: data.expires_at
}
}));
} else {
// Revert the UI change if server update failed
this.enabledValue = !enabled;
this.updateToggleState();
this.showFlashMessage('error', data.message || 'Failed to update location sharing');
}
} catch (error) {
console.error('Error updating location sharing:', error);
// Revert the UI change if request failed
this.enabledValue = !enabled;
this.updateToggleState();
this.showFlashMessage('error', 'Network error occurred while updating location sharing');
}
}
setupExpirationTimer() {
this.clearExpirationTimer();
if (this.enabledValue && this.expiresAtValue) {
const expiresAt = new Date(this.expiresAtValue);
const now = new Date();
const msUntilExpiration = expiresAt.getTime() - now.getTime();
if (msUntilExpiration > 0) {
// Set timer to automatically disable sharing when it expires
this.expirationTimer = setTimeout(() => {
this.enabledValue = false;
this.updateToggleState();
this.showFlashMessage('info', 'Location sharing has expired');
// Trigger update event
document.dispatchEvent(new CustomEvent('location-sharing:expired', {
detail: { userId: this.memberIdValue }
}));
}, msUntilExpiration);
// Also set up periodic updates to show countdown
this.updateExpirationCountdown();
this.countdownInterval = setInterval(() => {
this.updateExpirationCountdown();
}, 60000); // Update every minute
}
}
}
clearExpirationTimer() {
if (this.expirationTimer) {
clearTimeout(this.expirationTimer);
this.expirationTimer = null;
}
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
}
updateExpirationInfo(formattedTime) {
if (this.hasExpirationInfoTarget && formattedTime) {
this.expirationInfoTarget.textContent = `Expires ${formattedTime}`;
this.expirationInfoTarget.style.display = 'block';
} else if (this.hasExpirationInfoTarget) {
this.expirationInfoTarget.style.display = 'none';
}
}
updateExpirationCountdown() {
if (!this.hasExpirationInfoTarget || !this.expiresAtValue) return;
const expiresAt = new Date(this.expiresAtValue);
const now = new Date();
const msUntilExpiration = expiresAt.getTime() - now.getTime();
if (msUntilExpiration <= 0) {
this.expirationInfoTarget.textContent = 'Expired';
this.expirationInfoTarget.style.display = 'block';
return;
}
const hoursLeft = Math.floor(msUntilExpiration / (1000 * 60 * 60));
const minutesLeft = Math.floor((msUntilExpiration % (1000 * 60 * 60)) / (1000 * 60));
let timeText;
if (hoursLeft > 0) {
timeText = `${hoursLeft}h ${minutesLeft}m remaining`;
} else {
timeText = `${minutesLeft}m remaining`;
}
this.expirationInfoTarget.textContent = `Expires in ${timeText}`;
}
showFlashMessage(type, message) {
// Create a flash message element matching the project style (_flash.html.erb)
const flashContainer = document.getElementById('flash-messages') ||
this.createFlashContainer();
const bgClass = this.getFlashClasses(type);
const flashElement = document.createElement('div');
flashElement.className = `flex items-center ${bgClass} py-3 px-5 rounded-lg z-[6000]`;
flashElement.innerHTML = `
<div class="mr-4">${message}</div>
<button type="button">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
`;
// Add click handler to dismiss button
const dismissButton = flashElement.querySelector('button');
dismissButton.addEventListener('click', () => {
flashElement.classList.add('fade-out');
setTimeout(() => {
flashElement.remove();
// Remove the container if it's empty
if (flashContainer && !flashContainer.hasChildNodes()) {
flashContainer.remove();
}
}, 150);
});
flashContainer.appendChild(flashElement);
// Auto-remove after 5 seconds
setTimeout(() => {
if (flashElement.parentNode) {
flashElement.classList.add('fade-out');
setTimeout(() => {
flashElement.remove();
// Remove the container if it's empty
if (flashContainer && !flashContainer.hasChildNodes()) {
flashContainer.remove();
}
}, 150);
}
}, 5000);
}
createFlashContainer() {
const container = document.createElement('div');
container.id = 'flash-messages';
container.className = 'fixed top-5 right-5 flex flex-col gap-2 z-50';
document.body.appendChild(container);
return container;
}
getFlashClasses(type) {
switch (type) {
case 'error':
case 'alert':
return 'bg-red-100 text-red-700 border-red-300';
default:
return 'bg-blue-100 text-blue-700 border-blue-300';
}
}
// Helper method to check if user's own location sharing is enabled
// This can be used by other controllers
static getUserLocationSharingStatus() {
const toggleController = document.querySelector('[data-controller*="location-sharing-toggle"]');
if (toggleController) {
const controller = this.application.getControllerForElementAndIdentifier(toggleController, 'location-sharing-toggle');
return controller?.enabledValue || false;
}
return false;
}
}

View file

@ -207,6 +207,9 @@ export default class extends BaseController {
// Expose visits manager globally for location search integration
window.visitsManager = this.visitsManager;
// Expose maps controller globally for family integration
window.mapsController = this;
// Initialize layers for the layer control
const controlsLayer = {
Points: this.markersLayer,
@ -1841,4 +1844,77 @@ export default class extends BaseController {
this.locationSearch = new LocationSearch(this.map, this.apiKey, this.userTheme);
}
}
// Helper method for family controller to update layer control
updateLayerControl(additionalLayers = {}) {
if (!this.layerControl) return;
// Store which base and overlay layers are currently visible
const overlayStates = {};
let activeBaseLayer = null;
let activeBaseLayerName = null;
if (this.layerControl._layers) {
Object.values(this.layerControl._layers).forEach(layerObj => {
if (layerObj.overlay && layerObj.layer) {
// Store overlay layer states
overlayStates[layerObj.name] = this.map.hasLayer(layerObj.layer);
} else if (!layerObj.overlay && this.map.hasLayer(layerObj.layer)) {
// Store the currently active base layer
activeBaseLayer = layerObj.layer;
activeBaseLayerName = layerObj.name;
}
});
}
// Remove existing layer control
this.map.removeControl(this.layerControl);
// Create base controls layer object
const baseControlsLayer = {
Points: this.markersLayer || L.layerGroup(),
Routes: this.polylinesLayer || L.layerGroup(),
Tracks: this.tracksLayer || L.layerGroup(),
Heatmap: this.heatmapLayer || L.heatLayer([]),
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer || L.layerGroup(),
Photos: this.photoMarkers || L.layerGroup(),
"Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(),
"Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
};
// Merge with additional layers (like family members)
const controlsLayer = { ...baseControlsLayer, ...additionalLayers };
// Get base maps and re-add the layer control
const baseMaps = this.baseMaps();
this.layerControl = L.control.layers(baseMaps, controlsLayer).addTo(this.map);
// Restore the active base layer if we had one
if (activeBaseLayer && activeBaseLayerName) {
console.log(`Restoring base layer: ${activeBaseLayerName}`);
// Make sure the base layer is added to the map
if (!this.map.hasLayer(activeBaseLayer)) {
activeBaseLayer.addTo(this.map);
}
} else {
// If no active base layer was found, ensure we have a default one
console.log('No active base layer found, adding default');
const defaultBaseLayer = Object.values(baseMaps)[0];
if (defaultBaseLayer && !this.map.hasLayer(defaultBaseLayer)) {
defaultBaseLayer.addTo(this.map);
}
}
// Restore overlay layer visibility states
Object.entries(overlayStates).forEach(([name, wasVisible]) => {
const layer = controlsLayer[name];
if (layer && wasVisible && !this.map.hasLayer(layer)) {
layer.addTo(this.map);
}
});
}
}

View file

@ -4,7 +4,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable
has_many :points, dependent: :destroy, counter_cache: true
has_many :points, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :stats, dependent: :destroy
has_many :exports, dependent: :destroy
@ -217,13 +217,89 @@ inverse_of: :invited_by, dependent: :destroy
end
def family_sharing_enabled?
in_family?
# User must be in a family and have explicitly enabled location sharing
return false unless in_family?
sharing_settings = settings.dig('family', 'location_sharing')
return false if sharing_settings.blank?
# If it's a boolean (legacy support), return it
return sharing_settings if [true, false].include?(sharing_settings)
# If it's time-limited sharing, check if it's still active
if sharing_settings.is_a?(Hash)
return false unless sharing_settings['enabled'] == true
# Check if sharing has an expiration
expires_at = sharing_settings['expires_at']
return expires_at.blank? || Time.parse(expires_at) > Time.current
end
false
end
def update_family_location_sharing!(enabled, duration: nil)
return false unless in_family?
current_settings = settings || {}
current_settings['family'] ||= {}
if enabled
sharing_config = { 'enabled' => true }
# Add expiration if duration is specified
if duration.present?
expiration_time = case duration
when '1h'
1.hour.from_now
when '6h'
6.hours.from_now
when '12h'
12.hours.from_now
when '24h'
24.hours.from_now
when 'permanent'
nil # No expiration
else
# Custom duration in hours
duration.to_i.hours.from_now if duration.to_i > 0
end
sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time
sharing_config['duration'] = duration
end
current_settings['family']['location_sharing'] = sharing_config
else
current_settings['family']['location_sharing'] = { 'enabled' => false }
end
update!(settings: current_settings)
end
def family_sharing_expires_at
sharing_settings = settings.dig('family', 'location_sharing')
return nil unless sharing_settings.is_a?(Hash)
expires_at = sharing_settings['expires_at']
Time.parse(expires_at) if expires_at.present?
rescue ArgumentError
nil
end
def family_sharing_duration
settings.dig('family', 'location_sharing', 'duration') || 'permanent'
end
def latest_location_for_family
return nil unless family_sharing_enabled?
latest_point = points.order(timestamp: :desc).first
# Use select to only fetch needed columns and limit to 1 for efficiency
latest_point = points.select(:latitude, :longitude, :timestamp)
.order(timestamp: :desc)
.limit(1)
.first
return nil unless latest_point
{

View file

@ -0,0 +1,49 @@
# frozen_string_literal: true
class Families::Locations
attr_reader :user
def initialize(user)
@user = user
end
def call
return [] unless family_feature_enabled?
return [] unless user.in_family?
sharing_members = family_members_with_sharing_enabled
return [] unless sharing_members.any?
build_family_locations(sharing_members)
end
private
def family_feature_enabled?
DawarichSettings.family_feature_enabled?
end
def family_members_with_sharing_enabled
user.family.members
.where.not(id: user.id)
.select(&:family_sharing_enabled?)
end
def build_family_locations(sharing_members)
latest_points = sharing_members.map { |member| member.points.last }.compact
latest_points.map do |point|
next unless point
{
user_id: point.user_id,
email: point.user.email,
email_initial: point.user.email.first.upcase,
latitude: point.lat.to_f,
longitude: point.lon.to_f,
timestamp: point.timestamp.to_i,
updated_at: Time.at(point.timestamp.to_i)
}
end.compact
end
end

View file

@ -42,9 +42,8 @@
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Family Members -->
<div class="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700 rounded-lg p-6">
<div class="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700 rounded-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
<%= t('families.show.members_title', default: 'Family Members') %>
@ -58,23 +57,110 @@
<div class="space-y-3">
<% @members.each do |member| %>
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<!-- DaisyUI Card for each member -->
<div class="card bg-base-200 shadow-sm">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div class="flex-grow">
<!-- Member Info -->
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-12">
<span class="text-lg font-semibold">
<%= member.email&.first&.upcase || '?' %>
</span>
</div>
</div>
<div>
<div class="font-medium text-gray-900 dark:text-gray-100"><%= member.email %></div>
<div class="text-sm text-gray-500 dark:text-gray-400">
<%= member.family_membership.role.humanize %>
<h3 class="card-title text-base"><%= member.email %></h3>
<div class="flex items-center gap-2 mt-1">
<% if member.family_membership.role == 'owner' %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 ml-2">
<div class="badge badge-warning badge-sm">
<%= t('families.show.owner_badge', default: 'Owner') %>
</div>
<% else %>
<span class="text-sm opacity-60">
<%= member.family_membership.role.humanize %>
</span>
<% end %>
</div>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
<div class="text-xs opacity-50 mt-1">
<%= t('families.show.joined_on', default: 'Joined') %>
<%= member.family_membership.created_at.strftime('%b %d, %Y') %>
</div>
</div>
</div>
</div>
<!-- Location Sharing Controls - More Compact -->
<div class="ml-auto flex items-center gap-4">
<% if member == current_user %>
<!-- Own toggle - interactive (consolidated controller) -->
<div data-controller="location-sharing-toggle"
data-location-sharing-toggle-member-id-value="<%= member.id %>"
data-location-sharing-toggle-enabled-value="<%= member.family_sharing_enabled? %>"
data-location-sharing-toggle-family-id-value="<%= @family.id %>"
data-location-sharing-toggle-duration-value="<%= member.family_sharing_duration %>"
data-location-sharing-toggle-expires-at-value="<%= member.family_sharing_expires_at&.iso8601 %>"
class="flex items-center gap-3">
<span class="text-sm opacity-60">Location:</span>
<!-- Toggle Switch -->
<input type="checkbox"
class="toggle toggle-primary toggle-sm"
<%= 'checked' if member.family_sharing_enabled? %>
data-location-sharing-toggle-target="checkbox"
data-action="change->location-sharing-toggle#toggle">
<!-- Duration Dropdown (only visible when enabled) -->
<div class="<%= 'hidden' unless member.family_sharing_enabled? %>"
data-location-sharing-toggle-target="durationContainer">
<select class="select select-bordered select-xs w-28 h-full"
data-location-sharing-toggle-target="durationSelect"
data-action="change->location-sharing-toggle#changeDuration">
<option value="permanent" <%= 'selected' if member.family_sharing_duration == 'permanent' %>>Always</option>
<option value="1h" <%= 'selected' if member.family_sharing_duration == '1h' %>>1 hour</option>
<option value="6h" <%= 'selected' if member.family_sharing_duration == '6h' %>>6 hours</option>
<option value="12h" <%= 'selected' if member.family_sharing_duration == '12h' %>>12 hours</option>
<option value="24h" <%= 'selected' if member.family_sharing_duration == '24h' %>>24 hours</option>
</select>
</div>
<!-- Expiration Info (inline) -->
<% if member.family_sharing_enabled? && member.family_sharing_expires_at.present? %>
<div class="text-xs opacity-50"
data-location-sharing-toggle-target="expirationInfo">
• Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now
</div>
<% end %>
</div>
<% else %>
<!-- Other member's status - read-only indicator -->
<div class="flex items-center gap-2">
<span class="text-sm opacity-60">Location:</span>
<% if member.family_sharing_enabled? %>
<div class="w-3 h-3 bg-success rounded-full animate-pulse"></div>
<span class="text-xs text-success font-medium">
<%= member.family_sharing_duration == 'permanent' ? 'Always' : member.family_sharing_duration&.upcase %>
</span>
<% if member.family_sharing_expires_at.present? %>
<span class="text-xs opacity-50">
• Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now
</span>
<% end %>
<% else %>
<div class="w-3 h-3 bg-base-300 rounded-full"></div>
<span class="text-xs opacity-50">Disabled</span>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
@ -168,5 +254,4 @@
<% end %>
</div>
</div>
</div>
</div>

View file

@ -63,7 +63,7 @@
<div
id='map'
class="w-full z-0"
data-controller="maps points add-visit"
data-controller="maps points add-visit family-members"
data-points-target="map"
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"
@ -74,7 +74,10 @@
data-distance="<%= @distance %>"
data-points_number="<%= @points_number %>"
data-timezone="<%= Rails.configuration.time_zone %>"
data-features='<%= @features.to_json.html_safe %>'>
data-features='<%= @features.to_json.html_safe %>'
data-family-members-family-member-locations-value='<%= @family_member_locations.to_json.html_safe %>'
data-family-members-features-value='<%= @features.to_json.html_safe %>'
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen z-0">
<div id="fog" class="fog"></div>
</div>

View file

@ -11,7 +11,15 @@
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
<li>
<% if current_user.in_family? %>
<%= link_to 'Family', family_path(current_user.family), class: "#{active_class?(families_path)}" %>
<div data-controller="family-navbar-indicator"
data-family-navbar-indicator-enabled-value="<%= current_user.family_sharing_enabled? %>">
<%= link_to family_path(current_user.family), class: "#{active_class?(families_path)} flex items-center space-x-2" do %>
<span>Family</span>
<div data-family-navbar-indicator-target="indicator"
class="w-2 h-2 <%= current_user.family_sharing_enabled? ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %> rounded-full"
title="<%= current_user.family_sharing_enabled? ? 'Location sharing enabled' : 'Location sharing disabled' %>"></div>
<% end %>
</div>
<% else %>
<%= link_to 'Family', families_path, class: "#{active_class?(families_path)}" %>
<% end %>
@ -68,7 +76,15 @@
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
<li>
<% if current_user.in_family? %>
<%= link_to 'Family', family_path(current_user.family), class: "mx-1 #{active_class?(families_path)}" %>
<div data-controller="family-navbar-indicator"
data-family-navbar-indicator-enabled-value="<%= current_user.family_sharing_enabled? %>">
<%= link_to family_path(current_user.family), class: "mx-1 #{active_class?(families_path)} flex items-center space-x-2" do %>
<span>Family</span>
<div data-family-navbar-indicator-target="indicator"
class="w-2 h-2 <%= current_user.family_sharing_enabled? ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %> rounded-full"
title="<%= current_user.family_sharing_enabled? ? 'Location sharing enabled' : 'Location sharing disabled' %>"></div>
<% end %>
</div>
<% else %>
<%= link_to 'Family', families_path, class: "mx-1 #{active_class?(families_path)}" %>
<% end %>

View file

@ -62,6 +62,7 @@ Rails.application.routes.draw do
resources :families do
member do
delete :leave
patch :update_location_sharing
end
resources :invitations, except: %i[edit update], controller: 'family_invitations' do
member do
@ -170,6 +171,12 @@ Rails.application.routes.draw do
end
end
resources :families, only: [] do
collection do
get :locations
end
end
post 'subscriptions/callback', to: 'subscriptions#callback'
end
end