mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-09-04 17:06:43 +02:00
Merge branch 'vector-tiles' into main
This commit is contained in:
+285
-82
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
@@ -15,6 +16,7 @@ import '../widgets/node_tag_sheet.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import '../widgets/download_area_dialog.dart';
|
||||
import '../widgets/measured_sheet.dart';
|
||||
import '../widgets/navigation_sheet.dart';
|
||||
import '../widgets/search_bar.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/search_result.dart';
|
||||
@@ -31,11 +33,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
|
||||
late final AnimatedMapController _mapController;
|
||||
bool _editSheetShown = false;
|
||||
bool _navigationSheetShown = false;
|
||||
|
||||
// Track sheet heights for map positioning
|
||||
double _addSheetHeight = 0.0;
|
||||
double _editSheetHeight = 0.0;
|
||||
double _tagSheetHeight = 0.0;
|
||||
double _navigationSheetHeight = 0.0;
|
||||
|
||||
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
|
||||
bool _transitioningToEdit = false;
|
||||
@@ -162,7 +166,190 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
});
|
||||
}
|
||||
|
||||
void _openNavigationSheet() {
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_navigationSheetHeight = height;
|
||||
});
|
||||
},
|
||||
child: NavigationSheet(
|
||||
onStartRoute: _onStartRoute,
|
||||
onResumeRoute: _onResumeRoute,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_navigationSheetHeight = 0.0;
|
||||
});
|
||||
|
||||
// Handle different dismissal scenarios
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
if (appState.isSettingSecondPoint) {
|
||||
// If user dismisses sheet while setting second point, cancel everything
|
||||
debugPrint('[HomeScreen] Sheet dismissed during second point selection - canceling navigation');
|
||||
appState.cancelNavigation();
|
||||
} else if (appState.isInRouteMode && appState.showingOverview) {
|
||||
// If we're in route active mode and showing overview, just hide the overview
|
||||
debugPrint('[HomeScreen] Sheet dismissed during route overview - hiding overview');
|
||||
appState.hideRouteOverview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onStartRoute() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Get user location and check if we should auto-enable follow-me
|
||||
LatLng? userLocation;
|
||||
bool enableFollowMe = false;
|
||||
|
||||
try {
|
||||
userLocation = _mapViewKey.currentState?.getUserLocation();
|
||||
if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) {
|
||||
debugPrint('[HomeScreen] Auto-enabling follow-me mode - user within 1km of start');
|
||||
appState.setFollowMeMode(FollowMeMode.northUp);
|
||||
enableFollowMe = true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not get user location for auto follow-me: $e');
|
||||
}
|
||||
|
||||
// Start the route
|
||||
appState.startRoute();
|
||||
|
||||
// Zoom to level 14 and center appropriately
|
||||
_zoomAndCenterForRoute(enableFollowMe, userLocation, appState.routeStart);
|
||||
}
|
||||
|
||||
void _zoomAndCenterForRoute(bool followMeEnabled, LatLng? userLocation, LatLng? routeStart) {
|
||||
try {
|
||||
LatLng centerLocation;
|
||||
|
||||
if (followMeEnabled && userLocation != null) {
|
||||
// Center on user if follow-me is enabled
|
||||
centerLocation = userLocation;
|
||||
debugPrint('[HomeScreen] Centering on user location for route start');
|
||||
} else if (routeStart != null) {
|
||||
// Center on start pin if user is far away or no GPS
|
||||
centerLocation = routeStart;
|
||||
debugPrint('[HomeScreen] Centering on route start pin');
|
||||
} else {
|
||||
debugPrint('[HomeScreen] No valid location to center on');
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate to zoom 14 and center location
|
||||
_mapController.animateTo(
|
||||
dest: centerLocation,
|
||||
zoom: 14.0,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not zoom/center for route: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onResumeRoute() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Hide the overview
|
||||
appState.hideRouteOverview();
|
||||
|
||||
// Zoom and center for resumed route
|
||||
// For resume, we always center on user if GPS is available, otherwise start pin
|
||||
LatLng? userLocation;
|
||||
try {
|
||||
userLocation = _mapViewKey.currentState?.getUserLocation();
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not get user location for route resume: $e');
|
||||
}
|
||||
|
||||
_zoomAndCenterForRoute(
|
||||
appState.followMeMode != FollowMeMode.off, // Use current follow-me state
|
||||
userLocation,
|
||||
appState.routeStart
|
||||
);
|
||||
}
|
||||
|
||||
void _zoomToShowFullRoute(AppState appState) {
|
||||
if (appState.routeStart == null || appState.routeEnd == null) return;
|
||||
|
||||
try {
|
||||
// Calculate the bounds of the route
|
||||
final start = appState.routeStart!;
|
||||
final end = appState.routeEnd!;
|
||||
|
||||
// Find the center point between start and end
|
||||
final centerLat = (start.latitude + end.latitude) / 2;
|
||||
final centerLng = (start.longitude + end.longitude) / 2;
|
||||
final center = LatLng(centerLat, centerLng);
|
||||
|
||||
// Calculate distance between points to determine appropriate zoom
|
||||
final distance = const Distance().as(LengthUnit.Meter, start, end);
|
||||
double zoom;
|
||||
if (distance < 500) {
|
||||
zoom = 16.0;
|
||||
} else if (distance < 2000) {
|
||||
zoom = 14.0;
|
||||
} else if (distance < 10000) {
|
||||
zoom = 12.0;
|
||||
} else {
|
||||
zoom = 10.0;
|
||||
}
|
||||
|
||||
debugPrint('[HomeScreen] Zooming to show full route - distance: ${distance.toStringAsFixed(0)}m, zoom: $zoom');
|
||||
|
||||
_mapController.animateTo(
|
||||
dest: center,
|
||||
zoom: zoom,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not zoom to show full route: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onNavigationButtonPressed() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
debugPrint('[HomeScreen] Navigation button pressed - showRouteButton: ${appState.showRouteButton}, navigationMode: ${appState.navigationMode}');
|
||||
|
||||
if (appState.showRouteButton) {
|
||||
// Route button - show route overview and zoom to show route
|
||||
debugPrint('[HomeScreen] Showing route overview');
|
||||
appState.showRouteOverview();
|
||||
|
||||
// Zoom out a bit to show the full route when viewing overview
|
||||
_zoomToShowFullRoute(appState);
|
||||
} else {
|
||||
// Search button - enter search mode
|
||||
debugPrint('[HomeScreen] Entering search mode');
|
||||
try {
|
||||
final mapCenter = _mapController.mapController.camera.center;
|
||||
debugPrint('[HomeScreen] Map center: $mapCenter');
|
||||
appState.enterSearchMode(mapCenter);
|
||||
} catch (e) {
|
||||
// Controller not ready, use fallback location
|
||||
debugPrint('[HomeScreen] Map controller not ready: $e, using fallback');
|
||||
appState.enterSearchMode(LatLng(37.7749, -122.4194));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchResultSelected(SearchResult result) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Update navigation state with selected result
|
||||
appState.selectSearchResult(result);
|
||||
|
||||
// Jump to the search result location
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
@@ -246,12 +433,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
_editSheetShown = false;
|
||||
}
|
||||
|
||||
// Auto-open navigation sheet when needed - simplified logic (only in dev mode)
|
||||
if (kEnableNavigationFeatures) {
|
||||
final shouldShowNavSheet = appState.isInSearchMode || appState.showingOverview;
|
||||
if (shouldShowNavSheet && !_navigationSheetShown) {
|
||||
_navigationSheetShown = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet());
|
||||
} else if (!shouldShowNavSheet) {
|
||||
_navigationSheetShown = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass the active sheet height directly to the map
|
||||
final activeSheetHeight = _addSheetHeight > 0
|
||||
? _addSheetHeight
|
||||
: (_editSheetHeight > 0
|
||||
? _editSheetHeight
|
||||
: _tagSheetHeight);
|
||||
: (_navigationSheetHeight > 0
|
||||
? _navigationSheetHeight
|
||||
: _tagSheetHeight));
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
@@ -260,6 +460,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false, // Disable automatic back button
|
||||
title: SvgPicture.asset(
|
||||
'assets/deflock-logo.svg',
|
||||
height: 28,
|
||||
@@ -290,94 +491,96 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Search bar at the top
|
||||
LocationSearchBar(
|
||||
onResultSelected: _onSearchResultSelected,
|
||||
MapView(
|
||||
key: _mapViewKey,
|
||||
controller: _mapController,
|
||||
followMeMode: appState.followMeMode,
|
||||
sheetHeight: activeSheetHeight,
|
||||
selectedNodeId: _selectedNodeId,
|
||||
onNodeTap: openNodeTagSheet,
|
||||
onSearchPressed: kEnableNavigationFeatures ? _onNavigationButtonPressed : null,
|
||||
onUserGesture: () {
|
||||
if (appState.followMeMode != FollowMeMode.off) {
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Map takes the rest of the space
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
MapView(
|
||||
key: _mapViewKey,
|
||||
controller: _mapController,
|
||||
followMeMode: appState.followMeMode,
|
||||
sheetHeight: activeSheetHeight,
|
||||
selectedNodeId: _selectedNodeId,
|
||||
onNodeTap: openNodeTagSheet,
|
||||
onUserGesture: () {
|
||||
if (appState.followMeMode != FollowMeMode.off) {
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
}
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
],
|
||||
// Search bar (slides in when in search mode) - only in dev mode
|
||||
if (kEnableNavigationFeatures && appState.isInSearchMode)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LocationSearchBar(
|
||||
onResultSelected: _onSearchResultSelected,
|
||||
onCancel: () => appState.cancelNavigation(),
|
||||
),
|
||||
),
|
||||
// Bottom button bar (restored to original)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
],
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../app_state.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class NavigationSettingsScreen extends StatelessWidget {
|
||||
const NavigationSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('navigation.navigationSettings')),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Coming soon message
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Navigation Features',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Navigation and routing settings will be available here. Coming soon:\n\n'
|
||||
'• Surveillance avoidance distance\n'
|
||||
'• Route planning preferences\n'
|
||||
'• Search history management\n'
|
||||
'• Distance units (metric/imperial)',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Placeholder settings (disabled for now)
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.warning_outlined,
|
||||
title: locService.t('navigation.avoidanceDistance'),
|
||||
subtitle: locService.t('navigation.avoidanceDistanceSubtitle'),
|
||||
value: '100 ${locService.t('navigation.meters')}',
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.history,
|
||||
title: locService.t('navigation.searchHistory'),
|
||||
subtitle: locService.t('navigation.searchHistorySubtitle'),
|
||||
value: '10 searches',
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.straighten,
|
||||
title: locService.t('navigation.units'),
|
||||
subtitle: locService.t('navigation.unitsSubtitle'),
|
||||
value: locService.t('navigation.metric'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDisabledSetting(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required String value,
|
||||
}) {
|
||||
return Opacity(
|
||||
opacity: 0.5,
|
||||
child: ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right, size: 16),
|
||||
],
|
||||
),
|
||||
enabled: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'settings/sections/auth_section.dart';
|
||||
import 'settings/sections/upload_mode_section.dart';
|
||||
import 'settings/sections/queue_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/version_service.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
@@ -39,6 +40,18 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Only show navigation settings in development builds
|
||||
if (kEnableNavigationFeatures) ...[
|
||||
_buildNavigationTile(
|
||||
context,
|
||||
icon: Icons.navigation,
|
||||
title: locService.t('navigation.navigationSettings'),
|
||||
subtitle: locService.t('navigation.navigationSettingsSubtitle'),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/navigation'),
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
|
||||
_buildNavigationTile(
|
||||
context,
|
||||
icon: Icons.cloud_off,
|
||||
@@ -79,7 +92,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Version: $kClientVersion',
|
||||
'Version: ${VersionService().version}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user