Last active
April 1, 2025 10:42
-
-
Save pratikbutani/478ce0f8b0ffeb718a1d9410760c5a46 to your computer and use it in GitHub Desktop.
Flutter: Dynamic Multi-Timezone Clock Matrix
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'dart:math' as math; | |
import 'package:flutter/material.dart'; | |
void main() { | |
runApp(const MyApp()); | |
} | |
class MyApp extends StatelessWidget { | |
const MyApp({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: Colors.black), | |
home: Theme(data: ThemeData.dark(), child: const ClockWithThemeSelector()), | |
); | |
} | |
} | |
// Clock theme data class | |
class ClockThemeData { | |
final Color backgroundColor; | |
final Color clockBorderColor; | |
final Color activeFillColorStart; | |
final Color activeFillColorEnd; | |
final Color inactiveFillColorStart; | |
final Color inactiveFillColorEnd; | |
final Color activeHandColor; | |
final Color inactiveHandColor; | |
final Color centerDotColor; | |
final Color tickColor; | |
final double glowIntensity; | |
final String name; | |
const ClockThemeData({ | |
required this.backgroundColor, | |
required this.clockBorderColor, | |
required this.activeFillColorStart, | |
required this.activeFillColorEnd, | |
required this.inactiveFillColorStart, | |
required this.inactiveFillColorEnd, | |
required this.activeHandColor, | |
required this.inactiveHandColor, | |
required this.centerDotColor, | |
required this.tickColor, | |
required this.glowIntensity, | |
required this.name, | |
}); | |
@override | |
bool operator ==(Object other) => | |
identical(this, other) || | |
other is ClockThemeData && | |
other.backgroundColor == backgroundColor && | |
other.clockBorderColor == clockBorderColor && | |
other.name == name; | |
@override | |
int get hashCode => Object.hash(backgroundColor, clockBorderColor, name); | |
} | |
// Predefined themes | |
class ClockThemes { | |
static const ClockThemeData dark = ClockThemeData( | |
backgroundColor: Colors.black, | |
clockBorderColor: Color(0x4D9E9E9E), | |
activeFillColorStart: Color(0x4D9E9E9E), | |
activeFillColorEnd: Color(0x269E9E9E), | |
inactiveFillColorStart: Color(0x1A757575), | |
inactiveFillColorEnd: Color(0x0D757575), | |
activeHandColor: Colors.white, | |
inactiveHandColor: Color(0x66757575), | |
centerDotColor: Color(0x99FFFFFF), | |
tickColor: Color(0x4DFFFFFF), | |
glowIntensity: 3.0, | |
name: 'Dark', | |
); | |
static const ClockThemeData light = ClockThemeData( | |
backgroundColor: Color(0xFFF5F5F5), | |
clockBorderColor: Color(0x99616161), | |
activeFillColorStart: Color(0x33616161), | |
activeFillColorEnd: Color(0x1A616161), | |
inactiveFillColorStart: Color(0x1ABDBDBD), | |
inactiveFillColorEnd: Color(0x0DBDBDBD), | |
activeHandColor: Color(0xFF212121), | |
inactiveHandColor: Color(0x66BDBDBD), | |
centerDotColor: Color(0xDD212121), | |
tickColor: Color(0x66616161), | |
glowIntensity: 2.0, | |
name: 'Light', | |
); | |
static const ClockThemeData blue = ClockThemeData( | |
backgroundColor: Color(0xFF0D47A1), | |
clockBorderColor: Color(0x99BBDEFB), | |
activeFillColorStart: Color(0x33BBDEFB), | |
activeFillColorEnd: Color(0x1ABBDEFB), | |
inactiveFillColorStart: Color(0x1A64B5F6), | |
inactiveFillColorEnd: Color(0x0D64B5F6), | |
activeHandColor: Color(0xFFE3F2FD), | |
inactiveHandColor: Color(0x6690CAF9), | |
centerDotColor: Color(0xDDE3F2FD), | |
tickColor: Color(0x4DBBDEFB), | |
glowIntensity: 4.0, | |
name: 'Blue', | |
); | |
static const List<ClockThemeData> allThemes = [dark, light, blue]; | |
static bool isDarkColor(Color color) { | |
final luminance = color.computeLuminance(); | |
return luminance < 0.5; | |
} | |
} | |
// Country data class | |
class CountryData { | |
final String name; | |
final double utcOffset; | |
const CountryData(this.name, this.utcOffset); | |
DateTime getCurrentTime() { | |
final now = DateTime.now().toUtc(); | |
return now.add( | |
Duration( | |
hours: utcOffset.floor(), | |
minutes: ((utcOffset - utcOffset.floor()) * 60).round() | |
), | |
); | |
} | |
String getFormattedTime() { | |
final time = getCurrentTime(); | |
final hour = time.hour % 12 == 0 ? 12 : time.hour % 12; | |
final minute = time.minute.toString().padLeft(2, '0'); | |
final period = time.hour >= 12 ? 'PM' : 'AM'; | |
final date = '${time.month}/${time.day}/${time.year}'; | |
return '$hour:$minute $period, $date'; | |
} | |
@override | |
bool operator ==(Object other) => | |
identical(this, other) || other is CountryData && other.name == name && other.utcOffset == utcOffset; | |
@override | |
int get hashCode => Object.hash(name, utcOffset); | |
} | |
// Expanded list of countries | |
final List<CountryData> countries = [ | |
CountryData('USA', -5), | |
// Eastern Standard Time (General representation) | |
CountryData('Japan', 9), | |
CountryData('UK', 0), | |
CountryData('Germany', 1), | |
CountryData('Australia', 11), | |
// Eastern Standard Time (Sydney, Melbourne) | |
CountryData('Brazil', -3), | |
CountryData('China', 8), | |
CountryData('India', 5.5), | |
CountryData('Russia', 3), | |
// Moscow Time (UTC +3, but Russia spans UTC +2 to UTC +12) | |
CountryData('Canada', -5), | |
// Eastern Time (Canada has UTC -8 to UTC -3.5) | |
CountryData('France', 1), | |
CountryData('South Africa', 2), | |
CountryData('Mexico', -6), | |
CountryData('Argentina', -3), | |
CountryData('Egypt', 2), | |
CountryData('Nigeria', 1), | |
CountryData('Kenya', 3), | |
CountryData('Saudi Arabia', 3), | |
CountryData('UAE', 4), | |
CountryData('Thailand', 7), | |
CountryData('Vietnam', 7), | |
CountryData('South Korea', 9), | |
CountryData('New Zealand', 12), | |
CountryData('Fiji', 12), | |
// Can be UTC +13 during DST | |
CountryData('Chile', -4), | |
// Some regions shift to UTC -3 in summer | |
CountryData('Peru', -5), | |
CountryData('Spain', 1), | |
// Canary Islands are UTC 0 | |
CountryData('Italy', 1), | |
CountryData('Sweden', 1), | |
CountryData('Norway', 1), | |
CountryData('Turkey', 3), | |
// Turkey is permanently on UTC +3 | |
CountryData('Israel', 2), | |
CountryData('Iran', 3.5), | |
// Iran observes DST (UTC +3:30 or +4:30) | |
CountryData('Pakistan', 5), | |
CountryData('Nepal', 5.75), | |
// Corrected to UTC +5:45 | |
CountryData('Bangladesh', 6), | |
CountryData('Indonesia', 7), | |
// Western Indonesia (Jakarta); but ranges UTC +7 to +9 | |
CountryData('Philippines', 8), | |
CountryData('Singapore', 8), | |
CountryData('Malaysia', 8), | |
CountryData('Taiwan', 8), | |
CountryData('Hong Kong', 8), | |
CountryData('Papua New Guinea', 10), | |
CountryData('Solomon Islands', 11), | |
CountryData('Samoa', 13), | |
// Can be UTC +14 in DST | |
CountryData('Tonga', 13), | |
// Can be UTC +14 in DST | |
CountryData('Kiribati', 14), | |
// Line Islands (Kiribati spans UTC +12 to +14) | |
]; | |
final CountryData defaultDigitCountry = countries.firstWhere((country) => country.name == 'India'); | |
// Wrapper widget with clock, theme selector, and country dropdown | |
class ClockWithThemeSelector extends StatefulWidget { | |
const ClockWithThemeSelector({super.key}); | |
@override | |
State<ClockWithThemeSelector> createState() => _ClockWithThemeSelectorState(); | |
} | |
class _ClockWithThemeSelectorState extends State<ClockWithThemeSelector> with SingleTickerProviderStateMixin { | |
ClockThemeData _currentTheme = ClockThemes.dark; | |
late AnimationController _themeTransitionController; | |
late Animation<double> _themeTransition; | |
final ValueNotifier<CountryData> _selectedCountryNotifier = ValueNotifier(defaultDigitCountry); | |
@override | |
void initState() { | |
super.initState(); | |
_themeTransitionController = AnimationController(vsync: this, duration: const Duration(milliseconds: 500)); | |
_themeTransition = CurvedAnimation(parent: _themeTransitionController, curve: Curves.easeInOut); | |
} | |
@override | |
void dispose() { | |
_themeTransitionController.dispose(); | |
_selectedCountryNotifier.dispose(); | |
super.dispose(); | |
} | |
void _changeTheme(ClockThemeData newTheme) { | |
if (newTheme != _currentTheme) { | |
setState(() { | |
_currentTheme = newTheme; | |
_themeTransitionController.forward(from: 0.0); | |
}); | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
backgroundColor: _currentTheme.backgroundColor, | |
body: SafeArea( | |
child: Column( | |
children: [ | |
Expanded( | |
child: LayoutBuilder( | |
builder: (context, constraints) { | |
return AnimatedBuilder( | |
animation: _themeTransition, | |
builder: (context, child) { | |
return ValueListenableBuilder<CountryData>( | |
valueListenable: _selectedCountryNotifier, | |
builder: (context, selectedCountry, _) { | |
return ClockGrid( | |
theme: _currentTheme, | |
availableWidth: constraints.maxWidth, | |
availableHeight: constraints.maxHeight * 0.9, | |
selectedCountry: selectedCountry, | |
); | |
}, | |
); | |
}, | |
); | |
}, | |
), | |
), | |
_buildCountryDropdown(), | |
_buildThemeSelector(), | |
], | |
), | |
), | |
); | |
} | |
Widget _buildCountryDropdown() { | |
return Padding( | |
padding: const EdgeInsets.symmetric(vertical: 8.0), | |
child: ValueListenableBuilder<CountryData>( | |
valueListenable: _selectedCountryNotifier, | |
builder: (context, selectedCountry, _) { | |
final bool isDarkBackground = ClockThemes.isDarkColor(_currentTheme.backgroundColor); | |
final Color textColor = isDarkBackground ? Colors.white : Colors.black; | |
return DropdownButton<CountryData>( | |
value: selectedCountry, | |
onChanged: (CountryData? newValue) { | |
if (newValue != null) { | |
_selectedCountryNotifier.value = newValue; | |
} | |
}, | |
items: | |
countries.map((country) => DropdownMenuItem<CountryData>(value: country, child: Text(country.name))).toList() | |
..sort((a, b) => a.value!.name.compareTo(b.value!.name)), | |
dropdownColor: _currentTheme.backgroundColor, | |
style: TextStyle(color: textColor), | |
iconEnabledColor: textColor, | |
underline: Container(height: 2, color: textColor.withValues(alpha: 0.5)), | |
); | |
}, | |
), | |
); | |
} | |
Widget _buildThemeSelector() { | |
final bool isDarkBackground = ClockThemes.isDarkColor(_currentTheme.backgroundColor); | |
final Color textColor = isDarkBackground ? Colors.white : Colors.black; | |
return Container( | |
padding: const EdgeInsets.symmetric(vertical: 12.0), | |
child: Row( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: | |
ClockThemes.allThemes.map((theme) { | |
final isSelected = theme == _currentTheme; | |
return Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 8.0), | |
child: GestureDetector( | |
onTap: () => _changeTheme(theme), | |
child: AnimatedContainer( | |
duration: const Duration(milliseconds: 300), | |
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), | |
decoration: BoxDecoration( | |
color: isSelected ? textColor.withValues(alpha:0.2) : Colors.transparent, | |
borderRadius: BorderRadius.circular(20.0), | |
border: Border.all(color: isSelected ? textColor.withValues(alpha:0.6) : Colors.transparent, width: 2.0), | |
), | |
child: Text( | |
theme.name, | |
style: TextStyle( | |
color: isSelected ? textColor : textColor.withValues(alpha:0.7), | |
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, | |
fontSize: 16, | |
), | |
), | |
), | |
), | |
); | |
}).toList(), | |
), | |
); | |
} | |
} | |
// Digit patterns for 0-9 in a 5x7 grid | |
const Map<int, List<List<bool>>> digitPatterns = { | |
0: [ | |
[true, true, true, true, true], | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, true, true, true, true], | |
], | |
1: [ | |
[false, false, true, false, false], | |
[false, true, true, false, false], | |
[true, false, true, false, false], | |
[false, false, true, false, false], | |
[false, false, true, false, false], | |
[false, false, true, false, false], | |
[true, true, true, true, true], | |
], | |
2: [ | |
[true, true, true, true, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[true, true, true, true, true], | |
[true, false, false, false, false], | |
[true, false, false, false, false], | |
[true, true, true, true, true], | |
], | |
3: [ | |
[true, true, true, true, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[true, true, true, true, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[true, true, true, true, true], | |
], | |
4: [ | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, true, true, true, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
], | |
5: [ | |
[true, true, true, true, true], | |
[true, false, false, false, false], | |
[true, false, false, false, false], | |
[true, true, true, true, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[true, true, true, true, true], | |
], | |
6: [ | |
[true, true, true, true, true], | |
[true, false, false, false, false], | |
[true, false, false, false, false], | |
[true, true, true, true, true], | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, true, true, true, true], | |
], | |
7: [ | |
[true, true, true, true, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
], | |
8: [ | |
[true, true, true, true, true], | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, true, true, true, true], | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, true, true, true, true], | |
], | |
9: [ | |
[true, true, true, true, true], | |
[true, false, false, false, true], | |
[true, false, false, false, true], | |
[true, true, true, true, true], | |
[false, false, false, false, true], | |
[false, false, false, false, true], | |
[true, true, true, true, true], | |
], | |
}; | |
// Hand angles for digits 0-9 (in degrees) | |
const Map<int, List<List<int>>> digitAngles = { | |
0: [ | |
[30 + 360, 0 + 360, 0 + 360, 0 + 360, 150 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[150 + 360, 180 + 360, 180 + 360, 180 + 360, 30 + 360], | |
], | |
1: [ | |
[-1, -1, 90, -1, -1], | |
[-1, 320, 60 + 360, -1, -1], | |
[320, -1, 60 + 360, -1, -1], | |
[-1, -1, 60 + 360, -1, -1], | |
[-1, -1, 60 + 360, -1, -1], | |
[-1, -1, 60 + 360, -1, -1], | |
[-1, 0 + 360, 270, 180, 180], | |
], | |
2: [ | |
[0 + 360, 0 + 360, 0 + 360, 0 + 360, 0 + 360], | |
[-1, -1, -1, -1, 45 + 360], | |
[-1, -1, -1, -1, 45 + 360], | |
[180 + 360, 180 + 360, 180 + 360, 180 + 360, 180 + 360], | |
[135 + 360, -1, -1, -1, -1], | |
[135 + 360, -1, -1, -1, -1], | |
[0 + 360, 0 + 360, 0 + 360, 0 + 360, 0 + 360], | |
], | |
3: [ | |
[360, 0 + 360, 0 + 360, 0 + 360, 90 + 360], | |
[-1, -1, -1, -1, 180 + 360], | |
[-1, -1, -1, -1, 180 + 360], | |
[180 + 360, 180 + 360, 180 + 360, 180 + 360, 180 + 360], | |
[-1, -1, -1, -1, 180 + 360], | |
[-1, -1, -1, -1, 180 + 360], | |
[360, 0 + 360, 0 + 360, 0 + 360, 270], | |
], | |
4: [ | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[0 + 360, 0 + 360, 0 + 360, 0 + 360, 0 + 360], | |
[-1, -1, -1, -1, 60 + 360], | |
[-1, -1, -1, -1, 60 + 360], | |
[-1, -1, -1, -1, 60 + 360], | |
], | |
5: [ | |
[30 + 360, 0 + 360, 0 + 360, 0 + 360, 150 + 360], | |
[120 + 360, -1, -1, -1, -1], | |
[120 + 360, -1, -1, -1, -1], | |
[150 + 360, 180 + 360, 180 + 360, 180 + 360, 30 + 360], | |
[-1, -1, -1, -1, 60 + 360], | |
[-1, -1, -1, -1, 60 + 360], | |
[30 + 360, 0 + 360, 0 + 360, 0 + 360, 150 + 360], | |
], | |
6: [ | |
[30 + 360, 0 + 360, 0 + 360, 0 + 360, 150 + 360], | |
[120 + 360, -1, -1, -1, -1], | |
[120 + 360, -1, -1, -1, -1], | |
[150 + 360, 180 + 360, 180 + 360, 180 + 360, 30 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[30 + 360, 0 + 360, 0 + 360, 0 + 360, 150 + 360], | |
], | |
7: [ | |
[0 + 360, 0 + 360, 0 + 360, 0 + 360, 0 + 360], | |
[-1, -1, -1, -1, 45 + 360], | |
[-1, -1, -1, -1, 45 + 360], | |
[-1, -1, -1, -1, 45 + 360], | |
[-1, -1, -1, -1, 45 + 360], | |
[-1, -1, -1, -1, 45 + 360], | |
[-1, -1, -1, -1, 45 + 360], | |
], | |
8: [ | |
[30 + 360, 0 + 360, 0 + 360, 0 + 360, 150 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[150 + 360, 180 + 360, 180 + 360, 180 + 360, 30 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[120 + 360, -1, -1, -1, 60 + 360], | |
[30 + 360, 0 + 360, 0 + 360, 0 + 360, 150 + 360], | |
], | |
9: [ | |
[30 + 360, 0 + 360, 0 + 360, 0 + 360, 45], | |
[120 + 360, -1, -1, -1, 180 + 360], | |
[120 + 360, -1, -1, -1, 180 + 360], | |
[145 + 360, 180 + 360, 180 + 360, 180 + 360, 180 + 360], | |
[-1, -1, -1, -1, 180 + 360], | |
[-1, -1, -1, -1, 180 + 360], | |
[30 + 360, 0 + 360, 0 + 360, 0 + 360, 180 + 360], | |
], | |
}; | |
// Clock cache | |
class ClockCache { | |
static final Map<String, AnimatedClock> _cache = {}; | |
static AnimatedClock getClock( | |
bool isOn, | |
double targetAngle, | |
bool isColon, | |
ClockThemeData theme, | |
CountryData? country, | |
int digitBlockIndex, { | |
Key? key, | |
}) { | |
double safeAngle = targetAngle < -1 ? 0 : targetAngle; | |
final String cacheKey = '$isOn-$safeAngle-$isColon-${theme.name}-${country?.name ?? 'none'}-$digitBlockIndex'; | |
if (!_cache.containsKey(cacheKey)) { | |
_cache[cacheKey] = AnimatedClock( | |
key: key, | |
isOn: isOn, | |
targetAngle: safeAngle, | |
isColon: isColon, | |
theme: theme, | |
country: country, | |
digitBlockIndex: digitBlockIndex, | |
); | |
} | |
return _cache[cacheKey]!; | |
} | |
static void clearCache() { | |
_cache.clear(); | |
} | |
} | |
class ClockGrid extends StatefulWidget { | |
final ClockThemeData theme; | |
final double availableWidth; | |
final double availableHeight; | |
final CountryData selectedCountry; | |
const ClockGrid({ | |
super.key, | |
required this.theme, | |
required this.availableWidth, | |
required this.availableHeight, | |
required this.selectedCountry, | |
}); | |
@override | |
State<ClockGrid> createState() => _ClockGridState(); | |
} | |
class _ClockGridState extends State<ClockGrid> with SingleTickerProviderStateMixin { | |
final ValueNotifier<String> _secondsNotifier = ValueNotifier(''); | |
final ValueNotifier<String> _minutesNotifier = ValueNotifier(''); | |
final ValueNotifier<String> _hoursNotifier = ValueNotifier(''); | |
late Timer _timer; | |
String _lastTime = ''; | |
late AnimationController _fadeController; | |
late Animation<double> _fadeAnimation; | |
final List<List<bool>> _pixelGrid = List.generate(7, (_) => List.generate(36, (_) => false)); | |
final List<List<int>> _angleGrid = List.generate(7, (_) => List.generate(36, (_) => -1)); | |
final List<List<bool>> _isColonGrid = List.generate(7, (_) => List.generate(36, (_) => false)); | |
final List<List<bool>> _isColonClockGrid = List.generate(7, (_) => List.generate(36, (_) => false)); | |
final List<List<CountryData?>> _countryGrid = List.generate(7, (_) => List.generate(36, (_) => null)); | |
final List<List<int>> _digitBlockGrid = List.generate(7, (_) => List.generate(36, (_) => -1)); | |
final List<List<bool>> _isOffClockGrid = List.generate(7, (_) => List.generate(36, (_) => false)); | |
final math.Random _random = math.Random(); | |
@override | |
void initState() { | |
super.initState(); | |
_fadeController = AnimationController(vsync: this, duration: const Duration(milliseconds: 500)); | |
_fadeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(CurvedAnimation(parent: _fadeController, curve: Curves.linear)); | |
_setupGridLayout(); | |
_updatePixelGrid(updateUI: false); | |
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _updatePixelGrid()); | |
} | |
@override | |
void didUpdateWidget(ClockGrid oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (widget.selectedCountry != oldWidget.selectedCountry) { | |
// Update the country for digit clocks and colons | |
_updateCountryGridForDigitsAndColons(); | |
// Update the time immediately without waiting for the animation | |
_updatePixelGrid(updateUI: true); | |
// Run the fade animation (shorter duration) | |
_fadeController.duration = const Duration(milliseconds: 100); // Reduced from 300ms | |
_fadeController.forward().then((_) { | |
_fadeController.reverse(); | |
}); | |
} | |
} | |
void _setupGridLayout() { | |
List<List<int>> digitBlockColumns = [ | |
[0, 5], // Hours | |
[13, 18], // Minutes | |
[26, 31], // Seconds | |
]; | |
for (int block = 0; block < 6; block++) { | |
int startCol = digitBlockColumns[block ~/ 2][block % 2]; | |
for (int row = 0; row < 7; row++) { | |
for (int col = startCol; col < startCol + 5; col++) { | |
_countryGrid[row][col] = widget.selectedCountry; | |
_digitBlockGrid[row][col] = block; | |
} | |
} | |
} | |
for (int row = 0; row < 7; row++) { | |
if (row == 2 || row == 4) { | |
_isColonClockGrid[row][11] = true; | |
_countryGrid[row][11] = widget.selectedCountry; | |
_digitBlockGrid[row][11] = 1; | |
_isColonClockGrid[row][24] = true; | |
_countryGrid[row][24] = widget.selectedCountry; | |
_digitBlockGrid[row][24] = 3; | |
} | |
} | |
const int totalPositions = 7 * 36; | |
const int digitPositions = 6 * 5 * 7; | |
const int colonPositions = 2 * 2; | |
const int reservedPositions = digitPositions + colonPositions; | |
const int offClockCount = totalPositions - reservedPositions; | |
List<Map<String, int>> availablePositions = []; | |
for (int row = 0; row < 7; row++) { | |
for (int col = 0; col < 36; col++) { | |
if (!_isPositionReserved(row, col)) { | |
availablePositions.add({'row': row, 'col': col}); | |
} | |
} | |
} | |
availablePositions.shuffle(_random); | |
for (int i = 0; i < offClockCount && i < availablePositions.length; i++) { | |
int row = availablePositions[i]['row']!; | |
int col = availablePositions[i]['col']!; | |
_isOffClockGrid[row][col] = true; | |
_countryGrid[row][col] = countries[_random.nextInt(countries.length)]; | |
_digitBlockGrid[row][col] = -1; | |
} | |
} | |
bool _isPositionReserved(int row, int col) { | |
if ((col >= 0 && col < 10) || (col >= 13 && col < 23) || (col >= 26 && col < 36)) { | |
return true; | |
} | |
if ((col == 11 || col == 24) && (row == 2 || row == 4)) { | |
return true; | |
} | |
return false; | |
} | |
void _updateCountryGridForDigitsAndColons() { | |
List<List<int>> digitBlockColumns = [ | |
[0, 5], | |
[13, 18], | |
[26, 31], | |
]; | |
for (int block = 0; block < 6; block++) { | |
int startCol = digitBlockColumns[block ~/ 2][block % 2]; | |
for (int row = 0; row < 7; row++) { | |
for (int col = startCol; col < startCol + 5; col++) { | |
_countryGrid[row][col] = widget.selectedCountry; | |
} | |
} | |
} | |
for (int row = 0; row < 7; row++) { | |
if (row == 2 || row == 4) { | |
_countryGrid[row][11] = widget.selectedCountry; | |
_countryGrid[row][24] = widget.selectedCountry; | |
} | |
} | |
} | |
void _updatePixelGrid({bool updateUI = true}) { | |
final now = DateTime.now().toUtc().add( | |
Duration( | |
hours: widget.selectedCountry.utcOffset.floor(), | |
minutes: ((widget.selectedCountry.utcOffset - widget.selectedCountry.utcOffset.floor()) * 60).round(), | |
), | |
); | |
final hour = now.hour.toString().padLeft(2, '0'); | |
final minute = now.minute.toString().padLeft(2, '0'); | |
final second = now.second.toString().padLeft(2, '0'); | |
final currentTime = '$hour:$minute:$second'; | |
if (currentTime == _lastTime) return; | |
List<int> digits = [ | |
int.parse(hour[0]), | |
int.parse(hour[1]), | |
int.parse(minute[0]), | |
int.parse(minute[1]), | |
int.parse(second[0]), | |
int.parse(second[1]), | |
]; | |
if (_lastTime.isEmpty) { | |
for (int row = 0; row < 7; row++) { | |
for (int col = 0; col < 36; col++) { | |
_pixelGrid[row][col] = false; | |
_angleGrid[row][col] = -1; | |
_isColonGrid[row][col] = false; | |
} | |
} | |
} | |
List<List<int>> digitBlockColumns = [ | |
[0, 5], | |
[13, 18], | |
[26, 31], | |
]; | |
for (int block = 0; block < 6; block++) { | |
int subIdx = block % 2; | |
int startCol = digitBlockColumns[block ~/ 2][subIdx]; | |
int digitValue = digits[block]; | |
for (int row = 0; row < 7; row++) { | |
for (int col = startCol; col < startCol + 5; col++) { | |
_pixelGrid[row][col] = digitPatterns[digitValue]![row][col - startCol]; | |
_angleGrid[row][col] = digitAngles[digitValue]![row][col - startCol]; | |
} | |
} | |
} | |
_lastTime = currentTime; | |
if (updateUI) { | |
_secondsNotifier.value = second; | |
_minutesNotifier.value = minute; | |
_hoursNotifier.value = hour; | |
} | |
} | |
@override | |
void dispose() { | |
_timer.cancel(); | |
_fadeController.dispose(); | |
_secondsNotifier.dispose(); | |
_minutesNotifier.dispose(); | |
_hoursNotifier.dispose(); | |
ClockCache.clearCache(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
final double gridWidth = widget.availableWidth; | |
final double aspectRatio = 36 / 7; | |
final double gridHeight = gridWidth / aspectRatio; | |
final double adjustedGridWidth = gridHeight > widget.availableHeight ? widget.availableHeight * aspectRatio : gridWidth; | |
final double clockSize = adjustedGridWidth / 36; | |
final double spacing = math.max(2, clockSize / 10); | |
return ValueListenableBuilder<String>( | |
valueListenable: _secondsNotifier, | |
builder: (context, _, __) { | |
return ValueListenableBuilder<String>( | |
valueListenable: _minutesNotifier, | |
builder: (context, _, __) { | |
return ValueListenableBuilder<String>( | |
valueListenable: _hoursNotifier, | |
builder: (context, _, __) { | |
return Center( | |
child: Container( | |
width: adjustedGridWidth, | |
height: adjustedGridWidth / aspectRatio, | |
color: widget.theme.backgroundColor, | |
child: GridView.builder( | |
physics: const NeverScrollableScrollPhysics(), | |
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( | |
crossAxisCount: 36, | |
childAspectRatio: 1, | |
crossAxisSpacing: spacing, | |
mainAxisSpacing: spacing, | |
), | |
itemCount: 7 * 36, | |
padding: EdgeInsets.all(spacing), | |
itemBuilder: (context, index) { | |
int row = index ~/ 36; | |
int col = index % 36; | |
if (row < 0 || row >= 7 || col < 0 || col >= 36) { | |
return Container(); | |
} | |
if (_isColonClockGrid[row][col]) { | |
return RepaintBoundary( | |
key: ValueKey('colon_clock_$row$col'), | |
child: AnimatedBuilder( | |
animation: _fadeAnimation, | |
builder: (context, child) { | |
return Opacity(opacity: _fadeAnimation.value, child: child); | |
}, | |
child: ClockCache.getClock( | |
true, | |
-1, | |
true, | |
widget.theme, | |
_countryGrid[row][col], | |
_digitBlockGrid[row][col], | |
key: ValueKey('colon_anim_$row$col'), | |
), | |
), | |
); | |
} | |
if (_isOffClockGrid[row][col]) { | |
return RepaintBoundary( | |
key: ValueKey('off_clock_$row$col'), | |
child: ClockCache.getClock( | |
false, | |
-1, | |
false, | |
widget.theme, | |
_countryGrid[row][col], | |
_digitBlockGrid[row][col], | |
key: ValueKey('off_anim_$row$col'), | |
), | |
); | |
} | |
bool isOn = _pixelGrid[row][col]; | |
int angle = _angleGrid[row][col]; | |
bool isColon = _isColonGrid[row][col]; | |
CountryData? country = _countryGrid[row][col]; | |
int digitBlockIndex = _digitBlockGrid[row][col]; | |
return RepaintBoundary( | |
key: ValueKey('clock_$row$col'), | |
child: AnimatedBuilder( | |
animation: _fadeAnimation, | |
builder: (context, child) { | |
return Opacity(opacity: _fadeAnimation.value, child: child); | |
}, | |
child: ClockCache.getClock( | |
isOn, | |
angle.toDouble(), | |
isColon, | |
widget.theme, | |
country, | |
digitBlockIndex, | |
key: ValueKey('anim_$row$col'), | |
), | |
), | |
); | |
}, | |
), | |
), | |
); | |
}, | |
); | |
}, | |
); | |
}, | |
); | |
} | |
} | |
class AnimatedClock extends StatefulWidget { | |
final bool isOn; | |
final double targetAngle; | |
final bool isColon; | |
final ClockThemeData theme; | |
final CountryData? country; | |
final int digitBlockIndex; | |
const AnimatedClock({ | |
super.key, | |
required this.isOn, | |
required this.targetAngle, | |
this.isColon = false, | |
required this.theme, | |
this.country, | |
required this.digitBlockIndex, | |
}); | |
@override | |
State<AnimatedClock> createState() => _AnimatedClockState(); | |
} | |
class _AnimatedClockState extends State<AnimatedClock> with SingleTickerProviderStateMixin { | |
late double _handAngle; | |
late AnimationController _controller; | |
Animation<double>? _handAnimation; | |
late double _hourAngle; | |
late double _minuteAngle; | |
late double _secondAngle; | |
DateTime? _currentTime; | |
Timer? _countryTimer; | |
bool _isSecondsDigit = false; | |
@override | |
void initState() { | |
super.initState(); | |
String? keyString = widget.key?.toString(); | |
_isSecondsDigit = keyString != null && keyString.contains('_') && keyString.contains('26'); | |
final animationDuration = | |
_isSecondsDigit ? const Duration(milliseconds: 300) : const Duration(milliseconds: 500); | |
_controller = AnimationController(vsync: this, duration: animationDuration); | |
_handAngle = widget.targetAngle >= 0 ? widget.targetAngle : 0; | |
if (widget.isOn && !widget.isColon) { | |
_handAnimation = Tween<double>(begin: _handAngle, end: _handAngle).animate( | |
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), | |
); | |
_updateAnimation(); | |
_controller.forward(); | |
} | |
_initializeState(); | |
} | |
void _initializeState() { | |
if (widget.isOn && !widget.isColon) { | |
if (_handAnimation == null) { | |
_handAnimation = Tween<double>(begin: _handAngle, end: _handAngle).animate( | |
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), | |
); | |
} | |
_updateAnimation(); | |
_controller.forward(); | |
} else if (widget.country != null) { | |
_updateCountryHands(); | |
_countryTimer?.cancel(); | |
_countryTimer = Timer.periodic(const Duration(seconds: 1), (_) { | |
if (mounted) { | |
setState(() { | |
_updateCountryHands(); | |
}); | |
} | |
}); | |
} | |
} | |
// Replace the _updateCountryHands method in _AnimatedClockState class | |
void _updateCountryHands() { | |
if (widget.country != null) { | |
// Use the same method that generates the tooltip time | |
_currentTime = widget.country!.getCurrentTime(); | |
// Calculate the angles correctly | |
final hours = _currentTime!.hour; | |
final minutes = _currentTime!.minute; | |
final seconds = _currentTime!.second; | |
// Hour hand: 30 degrees per hour + 0.5 degrees per minute | |
_hourAngle = ((hours % 12) * 30 + minutes * 0.5) % 360; | |
// Minute hand: 6 degrees per minute | |
_minuteAngle = minutes * 6; | |
// Second hand: 6 degrees per second | |
_secondAngle = seconds * 6; | |
} | |
} | |
@override | |
void didUpdateWidget(AnimatedClock oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (widget.targetAngle != oldWidget.targetAngle) { | |
_handAngle = _controller.isAnimating && _handAnimation != null | |
? _handAnimation!.value | |
: (widget.isOn && !widget.isColon ? oldWidget.targetAngle : _handAngle); | |
_updateAnimation(); | |
_controller.reset(); | |
if (widget.isOn && !widget.isColon) _controller.forward(); | |
} | |
if (widget.isOn != oldWidget.isOn || widget.isColon != oldWidget.isColon || widget.country != oldWidget.country) { | |
_countryTimer?.cancel(); | |
if (!(widget.isOn && !widget.isColon)) { | |
_handAnimation = null; | |
} | |
_initializeState(); | |
} | |
} | |
@override | |
void dispose() { | |
_controller.dispose(); | |
_countryTimer?.cancel(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
Widget clockWidget; | |
if (widget.isOn && !widget.isColon) { | |
if (_handAnimation == null) { | |
_handAnimation = Tween<double>(begin: _handAngle, end: _handAngle).animate( | |
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), | |
); | |
_updateAnimation(); | |
_controller.forward(); | |
} | |
clockWidget = AnimatedBuilder( | |
animation: _controller, | |
builder: (context, _) { | |
final safeAngle = _handAnimation!.value >= 0 ? _handAnimation!.value : 0; | |
return CustomPaint( | |
painter: ClockPainter( | |
isOn: widget.isOn, | |
isColon: widget.isColon, | |
handAngle: safeAngle.toDouble(), | |
hourAngle: 0, | |
minuteAngle: 0, | |
secondAngle: 0, | |
showTicks: true, | |
theme: widget.theme, | |
), | |
size: const Size(50, 50), | |
); | |
}, | |
); | |
} else { | |
clockWidget = CustomPaint( | |
painter: ClockPainter( | |
isOn: widget.isOn, | |
isColon: widget.isColon, | |
handAngle: 0, | |
hourAngle: _hourAngle, | |
minuteAngle: _minuteAngle, | |
secondAngle: _secondAngle, | |
showTicks: widget.isColon || widget.country != null, | |
theme: widget.theme, | |
), | |
size: const Size(50, 50), | |
); | |
} | |
return Tooltip( | |
message: widget.digitBlockIndex != -1 | |
? "Make your eyes little close and see the time clearly." | |
: widget.country != null && _currentTime != null | |
? '${widget.country!.name}: ${_formatTime(_currentTime!)}' | |
: '', | |
child: GestureDetector( | |
onTap: () { | |
if (widget.country != null && widget.digitBlockIndex == -1) { | |
showDialog( | |
context: context, | |
builder: (context) => AlertDialog( | |
title: Text(widget.country!.name), | |
content: Text(_currentTime != null ? _formatTime(_currentTime!) : 'Loading...'), | |
actions: [ | |
TextButton( | |
onPressed: () => Navigator.pop(context), | |
child: const Text('Close'), | |
), | |
], | |
), | |
); | |
} | |
}, | |
child: clockWidget, | |
), | |
); | |
} | |
String _formatTime(DateTime time) { | |
// Correctly format the hour for 12-hour display | |
final hour = time.hour % 12 == 0 ? 12 : time.hour % 12; | |
final minute = time.minute.toString().padLeft(2, '0'); | |
final second = time.second.toString().padLeft(2, '0'); | |
final period = time.hour >= 12 ? 'PM' : 'AM'; | |
final date = '${time.month}/${time.day}/${time.year}'; | |
return '$hour:$minute:$second $period, $date'; | |
} | |
void _updateAnimation() { | |
if (widget.isOn && !widget.isColon) { | |
double targetAngle = widget.targetAngle >= 0 ? widget.targetAngle : 0; | |
_handAnimation = Tween<double>(begin: _handAngle, end: targetAngle).animate( | |
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), | |
); | |
_handAngle = targetAngle; | |
} | |
} | |
} | |
class ClockPainter extends CustomPainter { | |
final bool isOn; | |
final bool isColon; | |
final double handAngle; | |
final double hourAngle; | |
final double minuteAngle; | |
final double secondAngle; // Add this field | |
final bool showTicks; | |
final ClockThemeData theme; | |
const ClockPainter({ | |
required this.isOn, | |
required this.isColon, | |
required this.handAngle, | |
required this.hourAngle, | |
required this.minuteAngle, | |
required this.secondAngle, // Add this parameter | |
this.showTicks = false, | |
required this.theme, | |
}); | |
@override | |
void paint(Canvas canvas, Size size) { | |
final center = Offset(size.width / 2, size.height / 2); | |
final radius = size.width / 2; | |
final borderPaint = | |
Paint() | |
..color = theme.clockBorderColor | |
..style = PaintingStyle.stroke | |
..strokeWidth = 1; | |
canvas.drawCircle(center, radius, borderPaint); | |
if (showTicks && (isOn || isColon)) { | |
_drawTicks(canvas, center, radius); | |
} | |
if (isOn) { | |
if (isColon) { | |
final fillRect = Rect.fromCircle(center: center, radius: radius); | |
final gradient = RadialGradient(colors: [theme.activeFillColorStart, theme.activeFillColorEnd]).createShader(fillRect); | |
final fillPaint = | |
Paint() | |
..shader = gradient | |
..style = PaintingStyle.fill; | |
canvas.drawCircle(center, radius, fillPaint); | |
_drawClockHands(canvas, center, radius, hourAngle, minuteAngle, secondAngle, isColon: true); | |
} else { | |
final fillRect = Rect.fromCircle(center: center, radius: radius); | |
final gradient = RadialGradient(colors: [theme.activeFillColorStart, theme.activeFillColorEnd]).createShader(fillRect); | |
final fillPaint = | |
Paint() | |
..shader = gradient | |
..style = PaintingStyle.fill; | |
canvas.drawCircle(center, radius, fillPaint); | |
final handPaint = | |
Paint() | |
..color = theme.activeHandColor | |
..strokeWidth = 3 | |
..strokeCap = StrokeCap.round; | |
final handX = center.dx + radius * 0.9 * math.cos(handAngle * math.pi / 180); | |
final handY = center.dy + radius * 0.9 * math.sin(handAngle * math.pi / 180); | |
final glowPaint = | |
Paint() | |
..color = theme.activeHandColor.withValues(alpha:0.3) | |
..strokeWidth = 6 | |
..strokeCap = StrokeCap.round | |
..maskFilter = MaskFilter.blur(BlurStyle.normal, theme.glowIntensity); | |
canvas.drawLine(center, Offset(handX, handY), glowPaint); | |
canvas.drawLine(center, Offset(handX, handY), handPaint); | |
} | |
} else { | |
final fillRect = Rect.fromCircle(center: center, radius: radius); | |
final gradient = RadialGradient(colors: [theme.inactiveFillColorStart, theme.inactiveFillColorEnd]).createShader(fillRect); | |
final fillPaint = | |
Paint() | |
..shader = gradient | |
..style = PaintingStyle.fill; | |
canvas.drawCircle(center, radius, fillPaint); | |
_drawClockHands(canvas, center, radius, hourAngle, minuteAngle, secondAngle, isColon: false); | |
} | |
final centerPaint = Paint()..color = theme.centerDotColor; | |
canvas.drawCircle(center, 2, centerPaint); | |
} | |
void _drawClockHands( | |
Canvas canvas, | |
Offset center, | |
double radius, | |
double hourAngle, | |
double minuteAngle, | |
double secondAngle, { | |
required bool isColon, | |
}) { | |
final Color handColor = isColon ? theme.activeHandColor.withValues(alpha:0.9) : theme.inactiveHandColor; | |
// Draw hour hand - convert degrees to radians for the math functions | |
final hourPaint = Paint() | |
..color = handColor | |
..strokeWidth = 3.5 | |
..strokeCap = StrokeCap.round; | |
final hourX = center.dx + radius * 0.5 * math.cos((hourAngle - 90) * math.pi / 180); | |
final hourY = center.dy + radius * 0.5 * math.sin((hourAngle - 90) * math.pi / 180); | |
canvas.drawLine(center, Offset(hourX, hourY), hourPaint); | |
// Draw minute hand - convert degrees to radians for the math functions | |
final minutePaint = Paint() | |
..color = handColor | |
..strokeWidth = 2.5 | |
..strokeCap = StrokeCap.round; | |
final minuteX = center.dx + radius * 0.7 * math.cos((minuteAngle - 90) * math.pi / 180); | |
final minuteY = center.dy + radius * 0.7 * math.sin((minuteAngle - 90) * math.pi / 180); | |
canvas.drawLine(center, Offset(minuteX, minuteY), minutePaint); | |
// Draw second hand - convert degrees to radians for the math functions | |
final secondPaint = Paint() | |
..color = handColor.withValues(alpha:0.8) | |
..strokeWidth = 1.5 | |
..strokeCap = StrokeCap.round; | |
final secondX = center.dx + radius * 0.85 * math.cos((secondAngle - 90) * math.pi / 180); | |
final secondY = center.dy + radius * 0.85 * math.sin((secondAngle - 90) * math.pi / 180); | |
canvas.drawLine(center, Offset(secondX, secondY), secondPaint); | |
} | |
void _drawTicks(Canvas canvas, Offset center, double radius) { | |
final tickPaint = | |
Paint() | |
..color = theme.tickColor | |
..strokeWidth = 1; | |
for (int i = 0; i < 12; i++) { | |
final angle = i * 30 * math.pi / 180; | |
final outerX = center.dx + radius * 0.9 * math.cos(angle); | |
final outerY = center.dy + radius * 0.9 * math.sin(angle); | |
final innerX = center.dx + radius * 0.8 * math.cos(angle); | |
final innerY = center.dy + radius * 0.8 * math.sin(angle); | |
canvas.drawLine(Offset(innerX, innerY), Offset(outerX, outerY), tickPaint); | |
} | |
} | |
@override | |
bool shouldRepaint(covariant ClockPainter oldDelegate) { | |
return isOn != oldDelegate.isOn || | |
isColon != oldDelegate.isColon || | |
handAngle != oldDelegate.handAngle || | |
hourAngle != oldDelegate.hourAngle || | |
minuteAngle != oldDelegate.minuteAngle || | |
secondAngle != oldDelegate.secondAngle || // Add this condition | |
showTicks != oldDelegate.showTicks || | |
theme != oldDelegate.theme; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment