Skip to content

Instantly share code, notes, and snippets.

@pratikbutani
Last active April 1, 2025 10:42
Show Gist options
  • Save pratikbutani/478ce0f8b0ffeb718a1d9410760c5a46 to your computer and use it in GitHub Desktop.
Save pratikbutani/478ce0f8b0ffeb718a1d9410760c5a46 to your computer and use it in GitHub Desktop.
Flutter: Dynamic Multi-Timezone Clock Matrix
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