feat: harden geofence and distance diagnostics
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../app/error_message.dart';
|
||||
import '../features/homes/models/geofence_diagnostics.dart';
|
||||
import '../models/home_config.dart';
|
||||
import '../providers/providers.dart';
|
||||
import '../widgets/build_info_text.dart';
|
||||
@@ -16,7 +18,8 @@ class HomesScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<HomesScreen> createState() => _HomesScreenState();
|
||||
}
|
||||
|
||||
class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
class _HomesScreenState extends ConsumerState<HomesScreen>
|
||||
with WidgetsBindingObserver {
|
||||
late final UserLocationNotifier _userLocationNotifier;
|
||||
String? _switchingHomeId;
|
||||
String? _deletingHomeId;
|
||||
@@ -24,21 +27,37 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_userLocationNotifier = ref.read(userLocationProvider.notifier);
|
||||
Future.microtask(() => _userLocationNotifier.startWatching());
|
||||
Future.microtask(() async {
|
||||
await _userLocationNotifier.startWatching();
|
||||
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_userLocationNotifier.stopWatching();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
Future.microtask(_refreshEnvironmentState);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final homes = ref.watch(homesProvider);
|
||||
final currentHome = ref.watch(currentHomeProvider);
|
||||
final location = ref.watch(userLocationProvider);
|
||||
final geofenceState = ref.watch(geofenceDiagnosticsProvider);
|
||||
final activeDistanceKm = currentHome == null
|
||||
? null
|
||||
: location.distanceToKm(currentHome.latitude, currentHome.longitude);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -50,87 +69,110 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
Expanded(
|
||||
child: homes.isEmpty
|
||||
? const _EmptyHomesView()
|
||||
: ListView.builder(
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: homes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final home = homes[index];
|
||||
final isActive = currentHome?.id == home.id;
|
||||
final isSwitching = _switchingHomeId == home.id;
|
||||
final isDeleting = _deletingHomeId == home.id;
|
||||
final isBusy = isSwitching || isDeleting;
|
||||
final distKm = location.distanceToKm(
|
||||
home.latitude,
|
||||
home.longitude,
|
||||
);
|
||||
children: [
|
||||
_HomesOverviewCard(
|
||||
location: location,
|
||||
diagnostics: geofenceState.data,
|
||||
activeHome: currentHome,
|
||||
activeDistanceKm: activeDistanceKm,
|
||||
onRefresh: _refreshEnvironmentState,
|
||||
onRequestLocationPermission: _requestLocationPermission,
|
||||
onRequestBackgroundPermission:
|
||||
_requestGeofenceBackgroundPermission,
|
||||
onRequestNotificationsPermission:
|
||||
_requestGeofenceNotificationPermission,
|
||||
onOpenAppSettings: _openRelevantAppSettings,
|
||||
onOpenLocationSettings: _openLocationSettings,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
...homes.map((home) {
|
||||
final isActive = currentHome?.id == home.id;
|
||||
final isSwitching = _switchingHomeId == home.id;
|
||||
final isDeleting = _deletingHomeId == home.id;
|
||||
final isBusy = isSwitching || isDeleting;
|
||||
final distKm = location.distanceToKm(
|
||||
home.latitude,
|
||||
home.longitude,
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
enabled: !isBusy,
|
||||
leading: Icon(
|
||||
Icons.home,
|
||||
color: isActive
|
||||
? Colors.deepOrange
|
||||
: Colors.white38,
|
||||
size: 28,
|
||||
),
|
||||
title: Text(
|
||||
home.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
enabled: !isBusy,
|
||||
leading: Icon(
|
||||
Icons.home,
|
||||
color: isActive
|
||||
? Colors.deepOrange
|
||||
: Colors.white,
|
||||
: Colors.white38,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
subtitle: _HomeSubtitle(
|
||||
home: home,
|
||||
location: location,
|
||||
distKm: distKm,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isBusy)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
title: Text(
|
||||
home.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: isActive
|
||||
? Colors.deepOrange
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
subtitle: _HomeSubtitle(
|
||||
home: home,
|
||||
location: location,
|
||||
distKm: distKm,
|
||||
isActive: isActive,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isBusy)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.edit,
|
||||
size: 20,
|
||||
color: Colors.white38,
|
||||
),
|
||||
onPressed: () => _editHome(context, home),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.edit,
|
||||
size: 20,
|
||||
color: Colors.white38,
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 20,
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
onPressed: () =>
|
||||
_confirmDelete(context, home),
|
||||
),
|
||||
onPressed: () => _editHome(context, home),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 20,
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
onPressed: () => _confirmDelete(context, home),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: isBusy
|
||||
? null
|
||||
: () => _selectHome(context, home),
|
||||
),
|
||||
onTap: isBusy ? null : () => _selectHome(context, home),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 10),
|
||||
child: BuildInfoText(),
|
||||
const SafeArea(
|
||||
top: false,
|
||||
minimum: EdgeInsets.only(bottom: 10),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 6),
|
||||
child: BuildInfoText(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -158,9 +200,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
if (context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Не удалось выбрать дом: ${describeLoadError(e)}',
|
||||
),
|
||||
content: Text('Не удалось выбрать дом: ${describeLoadError(e)}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -171,16 +211,18 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _addHome(BuildContext context) {
|
||||
Navigator.of(
|
||||
Future<void> _addHome(BuildContext context) async {
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
|
||||
await _refreshEnvironmentState();
|
||||
}
|
||||
|
||||
void _editHome(BuildContext context, HomeConfig home) {
|
||||
Navigator.of(
|
||||
Future<void> _editHome(BuildContext context, HomeConfig home) async {
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
|
||||
await _refreshEnvironmentState();
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, HomeConfig home) {
|
||||
@@ -222,14 +264,16 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
if (deletedCurrentHome) {
|
||||
ref.read(authInfoProvider.notifier).clear();
|
||||
}
|
||||
await syncGeofenceTask(ref.read(homesProvider));
|
||||
await syncGeofenceTask(
|
||||
ref.read(homesProvider),
|
||||
currentHome: ref.read(currentHomeProvider),
|
||||
);
|
||||
await _refreshEnvironmentState();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Не удалось удалить дом: ${describeLoadError(e)}',
|
||||
),
|
||||
content: Text('Не удалось удалить дом: ${describeLoadError(e)}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -239,6 +283,45 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshEnvironmentState() async {
|
||||
await _userLocationNotifier.refresh();
|
||||
await _refreshGeofenceDiagnostics();
|
||||
}
|
||||
|
||||
Future<void> _refreshGeofenceDiagnostics() async {
|
||||
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
Future<void> _requestLocationPermission() async {
|
||||
await _userLocationNotifier.requestPermission();
|
||||
await _refreshGeofenceDiagnostics();
|
||||
}
|
||||
|
||||
Future<void> _openLocationSettings() async {
|
||||
await _userLocationNotifier.openLocationSettings();
|
||||
}
|
||||
|
||||
Future<void> _requestGeofenceBackgroundPermission() async {
|
||||
await ref
|
||||
.read(geofenceDiagnosticsProvider.notifier)
|
||||
.requestBackgroundLocationPermission();
|
||||
await _userLocationNotifier.refresh();
|
||||
}
|
||||
|
||||
Future<void> _requestGeofenceNotificationPermission() async {
|
||||
await ref
|
||||
.read(geofenceDiagnosticsProvider.notifier)
|
||||
.requestNotificationPermission();
|
||||
}
|
||||
|
||||
Future<void> _openRelevantAppSettings() async {
|
||||
if (ref.read(userLocationProvider).needsAppSettings) {
|
||||
await _userLocationNotifier.openAppSettings();
|
||||
return;
|
||||
}
|
||||
await ref.read(geofenceDiagnosticsProvider.notifier).openAppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyHomesView extends StatelessWidget {
|
||||
@@ -271,11 +354,13 @@ class _HomeSubtitle extends StatelessWidget {
|
||||
final HomeConfig home;
|
||||
final UserLocation location;
|
||||
final double? distKm;
|
||||
final bool isActive;
|
||||
|
||||
const _HomeSubtitle({
|
||||
required this.home,
|
||||
required this.location,
|
||||
required this.distKm,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -302,17 +387,270 @@ class _HomeSubtitle extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else if (home.hasCoordinates && !location.hasPosition)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 12, color: Colors.white24),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
location.error ?? 'Координаты заданы',
|
||||
style: const TextStyle(color: Colors.white24, fontSize: 11),
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 12, color: Colors.white24),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
location.error ?? 'Координаты заданы',
|
||||
style: const TextStyle(color: Colors.white24, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (home.geofenceReady && isActive)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.shield_moon_outlined,
|
||||
size: 12,
|
||||
color: Colors.deepOrangeAccent,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'Geofence включён',
|
||||
style: TextStyle(
|
||||
color: Colors.deepOrangeAccent,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (home.geofenceReady)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.shield_moon_outlined,
|
||||
size: 12,
|
||||
color: Colors.white24,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'Geofence включён',
|
||||
style: TextStyle(color: Colors.white24, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomesOverviewCard extends StatelessWidget {
|
||||
final UserLocation location;
|
||||
final GeofenceDiagnostics diagnostics;
|
||||
final HomeConfig? activeHome;
|
||||
final double? activeDistanceKm;
|
||||
final Future<void> Function() onRefresh;
|
||||
final Future<void> Function() onRequestLocationPermission;
|
||||
final Future<void> Function() onRequestBackgroundPermission;
|
||||
final Future<void> Function() onRequestNotificationsPermission;
|
||||
final Future<void> Function() onOpenAppSettings;
|
||||
final Future<void> Function() onOpenLocationSettings;
|
||||
|
||||
const _HomesOverviewCard({
|
||||
required this.location,
|
||||
required this.diagnostics,
|
||||
required this.activeHome,
|
||||
required this.activeDistanceKm,
|
||||
required this.onRefresh,
|
||||
required this.onRequestLocationPermission,
|
||||
required this.onRequestBackgroundPermission,
|
||||
required this.onRequestNotificationsPermission,
|
||||
required this.onOpenAppSettings,
|
||||
required this.onOpenLocationSettings,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = activeHome == null
|
||||
? 'Статус автоматизации'
|
||||
: 'Активный дом: ${activeHome!.name}';
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_overviewIcon(location, diagnostics),
|
||||
color: _overviewColor(location, diagnostics),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_overviewPrimaryText(location, diagnostics),
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
if (_overviewSecondaryText(location, diagnostics, activeDistanceKm)
|
||||
case final secondary?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
secondary,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Обновить'),
|
||||
),
|
||||
if (location.canRequestPermission)
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRequestLocationPermission,
|
||||
icon: const Icon(Icons.my_location),
|
||||
label: const Text('Разрешить геолокацию'),
|
||||
),
|
||||
if (location.needsLocationSettings)
|
||||
OutlinedButton.icon(
|
||||
onPressed: onOpenLocationSettings,
|
||||
icon: const Icon(Icons.location_searching),
|
||||
label: const Text('Включить GPS'),
|
||||
),
|
||||
if (diagnostics.canRequestBackgroundLocation)
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRequestBackgroundPermission,
|
||||
icon: const Icon(Icons.shield_moon_outlined),
|
||||
label: const Text('Доступ всегда'),
|
||||
),
|
||||
if (diagnostics.canRequestNotifications)
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRequestNotificationsPermission,
|
||||
icon: const Icon(Icons.notifications_active_outlined),
|
||||
label: const Text('Уведомления'),
|
||||
),
|
||||
if (location.needsAppSettings ||
|
||||
diagnostics.canRequestBackgroundLocation)
|
||||
OutlinedButton.icon(
|
||||
onPressed: onOpenAppSettings,
|
||||
icon: const Icon(Icons.settings),
|
||||
label: const Text('Настройки'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _overviewPrimaryText(
|
||||
UserLocation location,
|
||||
GeofenceDiagnostics diagnostics,
|
||||
) {
|
||||
if (!location.hasPosition) {
|
||||
return location.error ?? 'Позиция устройства пока недоступна.';
|
||||
}
|
||||
|
||||
return switch (diagnostics.status) {
|
||||
GeofenceStatusKind.ready =>
|
||||
'Расстояние считается, geofence активен и готов выключить свет.',
|
||||
GeofenceStatusKind.triggered =>
|
||||
'Расстояние считается, geofence уже сработал для активного дома.',
|
||||
GeofenceStatusKind.cooldown =>
|
||||
'Расстояние считается, но после сбоя geofence сейчас на паузе.',
|
||||
GeofenceStatusKind.notificationsPermissionDenied =>
|
||||
'Расстояние считается, но уведомления для geofence сейчас запрещены.',
|
||||
GeofenceStatusKind.backgroundPermissionDenied =>
|
||||
'Расстояние считается, но без доступа "Всегда" geofence в фоне будет кастрирован.',
|
||||
GeofenceStatusKind.locationServicesDisabled =>
|
||||
'Геолокация выключена, поэтому и расстояния, и geofence сейчас мёртвые.',
|
||||
GeofenceStatusKind.locationPermissionDenied =>
|
||||
'Без разрешения на геолокацию тут нечего считать.',
|
||||
GeofenceStatusKind.disabled =>
|
||||
'Расстояние считается, но geofence для активного дома выключен.',
|
||||
GeofenceStatusKind.missingCoordinates =>
|
||||
'Расстояние считается, но у активного дома нет координат для geofence.',
|
||||
GeofenceStatusKind.noActiveHome =>
|
||||
'Выбери активный дом, и тогда автоматика станет осмысленной.',
|
||||
};
|
||||
}
|
||||
|
||||
String? _overviewSecondaryText(
|
||||
UserLocation location,
|
||||
GeofenceDiagnostics diagnostics,
|
||||
double? activeDistanceKm,
|
||||
) {
|
||||
final parts = <String>[];
|
||||
|
||||
if (activeDistanceKm != null) {
|
||||
parts.add('До активного дома: ${formatDistance(activeDistanceKm)}.');
|
||||
}
|
||||
|
||||
if (location.updatedAt != null) {
|
||||
parts.add('Точка: ${_formatTimestamp(location.updatedAt!)}.');
|
||||
}
|
||||
|
||||
final secondary = diagnostics.secondaryMessage;
|
||||
if (secondary != null && secondary.isNotEmpty) {
|
||||
parts.add(secondary);
|
||||
}
|
||||
|
||||
if (parts.isEmpty) return null;
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
IconData _overviewIcon(UserLocation location, GeofenceDiagnostics diagnostics) {
|
||||
if (!location.hasPosition) {
|
||||
return Icons.location_off_outlined;
|
||||
}
|
||||
|
||||
return switch (diagnostics.status) {
|
||||
GeofenceStatusKind.ready => Icons.shield_moon_outlined,
|
||||
GeofenceStatusKind.triggered => Icons.check_circle_outline,
|
||||
GeofenceStatusKind.cooldown => Icons.timer_outlined,
|
||||
GeofenceStatusKind.notificationsPermissionDenied =>
|
||||
Icons.notifications_off_outlined,
|
||||
GeofenceStatusKind.backgroundPermissionDenied => Icons.location_searching,
|
||||
_ => Icons.info_outline,
|
||||
};
|
||||
}
|
||||
|
||||
Color _overviewColor(UserLocation location, GeofenceDiagnostics diagnostics) {
|
||||
if (!location.hasPosition) {
|
||||
return Colors.redAccent;
|
||||
}
|
||||
|
||||
return switch (diagnostics.status) {
|
||||
GeofenceStatusKind.ready => Colors.greenAccent,
|
||||
GeofenceStatusKind.triggered => Colors.deepOrangeAccent,
|
||||
GeofenceStatusKind.cooldown => Colors.amberAccent,
|
||||
GeofenceStatusKind.notificationsPermissionDenied ||
|
||||
GeofenceStatusKind.backgroundPermissionDenied => Colors.deepOrangeAccent,
|
||||
_ => Colors.white54,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final local = timestamp.toLocal();
|
||||
final hour = local.hour.toString().padLeft(2, '0');
|
||||
final minute = local.minute.toString().padLeft(2, '0');
|
||||
final second = local.second.toString().padLeft(2, '0');
|
||||
return '$hour:$minute:$second';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user