S-1335 clock radio
Table of Contents
Developer sets up development environment
Follow Flutter site's Getting Started section, and maybe use VSCodium as IDE as it has a well integrated Dart/Flutter plugin.
S-1335 clock radio
The logo for the app was made first and the name is derived from it. I figured it's a boring appliance, so a boring code of letters and numbers similar to hardware clock radios should fit fine. The rest will now explain the inner workings of this software.
Notably, S-1335 doesn't have tests and the state is limited to simple but fairly omnipresent controllers, meaning no Provider
or similar architectures were implemented. Coming from Haskell and immutability, I also heavily leaned on StatelessWidget
rather than StatefulWidget
class. It's a second iteration of my first Flutter project.
The pubspec.yaml
for S-1335 has a font asset set to allow local non-CDN loading.
name: s1335_clock_radio description: "S-1335 clock radio" # Prevent accidental publishing to pub.dev. publish_to: 'none' version: 1.0.0+1 environment: sdk: '>=3.4.3 <4.0.0' dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter # SVG rendering flutter_svg: any # Audio media_kit: ^1.1.11 media_kit_libs_audio: ^1.0.5 media_kit_native_event_loop: ^1.0.9 # Persist settings shared_preferences: ^2.2.3 # Non-white loading screen when loading app on all platforms flutter_native_splash: ^2.4.1 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 flutter: uses-material-design: true # Enable generation of localized Strings from arb files. generate: true assets: - assets/images/ - assets/images/led_segments/ - assets/sounds/ fonts: - family: Roboto fonts: - asset: assets/fonts/Roboto-Regular.ttf flutter_native_splash: color: "#000000"
analysis_options.yaml
has select strong-mode
options enabled to have sensible guard rails. prefer_single_quotes
seems idiomatic.
# This file configures the analyzer, which statically analyzes Dart code to # check for errors, warnings, and lints. # # The issues identified by the analyzer are surfaced in the UI of Dart-enabled # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be # invoked from the command line by running `flutter analyze`. # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at https://dart.dev/lints. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code # or a specific dart file by using the `// ignore: name_of_lint` and # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: prefer_single_quotes: true # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options analyzer: strong-mode: implicit-casts: false implicit-dynamic: false
main.dart
initializes program controllers (and controllers their associated services). It then passes the controllers to the app widget itself.
import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'src/app.dart'; import 'src/settings/settings_controller.dart'; import 'src/radio/radio_controller.dart'; import 'src/clock/clock_controller.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); final settings = SettingsController.create(); await settings.loadSettings(); MediaKit.ensureInitialized(); final radio = RadioController.create(settings); final clock = ClockController.create( radio.play, settings, ); clock.startClock(); final app = ClockRadio( clock: clock, radio: radio, settings: settings, ); runApp(app); }
app.dart
mostly follows the basic skeleton template. The ClockRadio
app widget rebuilds and updates controllers whenever settings change.
Widget build(BuildContext context) { return ListenableBuilder( listenable: settings, builder: (BuildContext context, Widget? child) { // Settings have changed: if (settings.alarmSet) { clock.setAlarm(settings.alarm); } else { clock.setAlarm(null); } clock.setLocation( settings.latitude, settings.longitude); if (settings.uiScale == null) { settings .updateUIScale(MediaQuery.of(context).devicePixelRatio); }
The default dark mode theme is used, but most views use true black as background outside user controls. The router shows how RadioView
, ClockView
, and SettingsView
come together into a PageView
widget, which allows swiping between these three views.
onGenerateRoute: (RouteSettings routeSettings) { return MaterialPageRoute<void>( settings: routeSettings, builder: (BuildContext context) { switch (routeSettings.name) { case LocationView.routeName: return LocationView(settings: settings); default: return PageView( controller: PageController(initialPage: 1), physics: const SnappyPageViewScrollPhysics(), children: <Widget>[ RadioView( radio: radio, settings: settings, ), ListenableBuilder( listenable: clock, builder: (BuildContext context, Widget? child) { return ClockView( clock: clock.buildClock(), radio: radio, showIntro: settings.intro, ); }, ), SettingsView( settings: settings, ), ], ); } }, ); },
To allow swiping also when using mouse, we need to set up a custom MaterialScrollBehaviour
.
class MouseAndTouchDragBehavior extends MaterialScrollBehavior { /// Allow dragging PageView with a mouse @override Set<PointerDeviceKind> get dragDevices => { PointerDeviceKind.touch, PointerDeviceKind.mouse, }; }
The default PageView
physics take ages, so we need to make it snappier using a custom ScrollPhysics
.
class SnappyPageViewScrollPhysics extends ScrollPhysics { const SnappyPageViewScrollPhysics({super.parent}); @override SnappyPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) { return SnappyPageViewScrollPhysics(parent: buildParent(ancestor)!); } @override SpringDescription get spring => const SpringDescription( mass: 80, stiffness: 100, damping: 0.8, ); }
User opens app and sees initial UI
The opening page in the PageView
is ClockView
and its child widget clock
. ClockView
essentially does three things: it shows introductory text _intro
, a clock face _clock
, and catches any taps to play and stop the radio
.
import 'package:flutter/material.dart'; import '../introduction/introduction.dart'; import '/src/radio/radio_controller.dart'; class ClockView extends StatelessWidget { const ClockView( {super.key, required this.clock, required this.radio, required this.showIntro}); static const routeName = '/'; final StatelessWidget clock; final RadioController radio; final bool showIntro; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color.fromARGB(255, 0, 0, 0), body: Builder(builder: (BuildContext context) { return Introduction( intro: _intro(context), show: showIntro, child: _clock(context), ); }), ); } // Widget Widget _clock(BuildContext context) { return InkWell( hoverColor: const Color.fromARGB(255, 0, 0, 0), onTap: () { radio.toggle(); }, child: Center( child: clock.build(context), ), ); } Widget _intro(BuildContext context) { return Align( alignment: Alignment.topCenter, child: Padding( padding: const EdgeInsets.all(16.0), child: Column( children: <Widget>[ Text( style: DefaultTextStyle.of(context).style.apply( color: const Color.fromARGB(255, 192, 192, 192), fontSizeFactor: 1.6, ), 'Drag left / right for options'), Text( style: DefaultTextStyle.of(context).style.apply( color: const Color.fromARGB(255, 192, 192, 192), fontSizeFactor: 1.6, ), 'Tap to toggle radio'), ], ), ), ); } }
The Introduction
widget controls whether _intro
and _clock
or only _clock
is shown. The class shows the intro widget if show
is set and then turns it invisible after a time.
import 'package:flutter/material.dart'; class Introduction extends StatefulWidget { const Introduction({ super.key, required this.child, required this.intro, required this.show, }); final Widget child; final Widget intro; final bool show; @override State<StatefulWidget> createState() => _Introduction(); } class _Introduction extends State<Introduction> { bool _visible = true; @override Widget build(BuildContext context) { if (widget.show) { return Stack( children: <Widget>[ widget.child, Visibility(visible: _visible, child: widget.intro), ], ); } else { return widget.child; } } @override void initState() { super.initState(); Future.delayed(const Duration(seconds: 10), () { if (mounted) { setState(() { _visible = false; }); } }); } }
clock
widget was produced earlier in app.dart
by ClockController.buildClock
method.
StatelessWidget buildClock() { switch (_settings.clockFace) { case ClockFace.led: return LedClock(clock: this); case ClockFace.solar: return SolarClock(clock: this); } }
The default clock face is LedClock
as set in SettingsService
.
Future<ClockFace> clockFace() async { final String? face = prefs.getString('clockFace'); switch (face) { case 'led': return ClockFace.led; case 'solar': return ClockFace.solar; default: return ClockFace.led; } }
ClockController
has a timer running to recreate Clock
& check alarm each minute on the point. ListenableBuilder
in app.dart
widget tree will be listening to ClockController
to repaint the clock face.
void startClock() { if(_settings.alarmSet) { _clock = Clock.now(old: _clock, alarm: _settings.alarm); } else { _clock = Clock.now(old: _clock); } if (_clock.isAlarmRinging) { _alarmCallback(); } Timer(Duration(seconds: 60 - DateTime.now().second), startClock); notifyListeners(); }
Clock
is an immutable class that tells current time, and collects and derives associated info from ClockController
. Clock faces are then painted based on Clock
.
import 'package:flutter/material.dart'; @immutable class Clock { const Clock( this.time, this.alarm, this.tzOffset, this.nthDayOfYear, this.userLatitude, this.userLongitude, ); final TimeOfDay time; final TimeOfDay? alarm; final Duration tzOffset; final int nthDayOfYear; final double userLatitude; final double userLongitude; int get hours => time.hour; int get minutes => time.minute; int? get alarmH => alarm?.hour; int? get alarmM => alarm?.minute; factory Clock.now({ Clock? old, double? userLatitude, double? userLongitude, TimeOfDay? alarm, }) { final daysSinceJan1 = DateTime.now() .difference(DateTime(DateTime.now().year, 1, 1, 0, 0)) .inDays; return Clock( TimeOfDay.now(), alarm, DateTime.now().timeZoneOffset, daysSinceJan1 + 1, userLatitude ?? old?.userLatitude ?? 0.0, userLongitude ?? old?.userLongitude ?? 0.0, ); } bool get isAlarmRinging { return time == alarm; } }
LED clock face
The LED clock face employs an array of LED segments: four 7-segment numbers, dots, and alarm elements. _powerLedElements
encodes hours and minutes into a Map
. Each of the elements in the Map
references to an SVG file, to a total of 30 drivable elements (barring potential AM/PM elements in the future).
Map<String, bool> _powerLedElements( int hours, int minutes, int? alarmH, int? alarmM, ) { final Map<String, List<bool>> typography = { '0': [true, true, true, true, true, true, false], '1': [false, true, true, false, false, false, false], '2': [true, true, false, true, true, false, true], '3': [true, true, true, true, false, false, true], '4': [false, true, true, false, false, true, true], '5': [true, false, true, true, false, true, true], '6': [true, false, true, true, true, true, true], '7': [true, true, true, false, false, false, false], '8': [true, true, true, true, true, true, true], '9': [true, true, true, true, false, true, true], }; final List<bool> elementOff = List.filled(7, false); final strHours = hours.toString(); String? digit1 = (strHours.length > 1) ? strHours[0] : null; List<bool> leds1 = typography[digit1] ?? elementOff; String digit2 = (strHours.length > 1) ? strHours[1] : strHours[0]; List<bool> leds2 = typography[digit2] ?? elementOff; final strMins = minutes.toString().padLeft(2, '0'); String digit3 = strMins[0]; List<bool> leds3 = typography[digit3] ?? elementOff; String digit4 = strMins[1]; List<bool> leds4 = typography[digit4] ?? elementOff; return { 'alarm': alarmH != null && alarmM != null, 'dots': true, '1a': leds1[0], '1b': leds1[1], '1c': leds1[2], '1d': leds1[3], '1e': leds1[4], '1f': leds1[5], '1g': leds1[6], '2a': leds2[0], '2b': leds2[1], '2c': leds2[2], '2d': leds2[3], '2e': leds2[4], '2f': leds2[5], '2g': leds2[6], '3a': leds3[0], '3b': leds3[1], '3c': leds3[2], '3d': leds3[3], '3e': leds3[4], '3f': leds3[5], '3g': leds3[6], '4a': leds4[0], '4b': leds4[1], '4c': leds4[2], '4d': leds4[3], '4e': leds4[4], '4f': leds4[5], '4g': leds4[6], }; }
Individual SVGs were derived from breaking apart the following artesanally drawn vector image (inspired by my now-dead clock radio).
The clock face should have same physical size regardless of screen DPI. The default size we're aiming for roughly is 3.5" x 1" WxH, which fits nicely on a 1st gen iPhone SE. clock.uiScale
gives us the device DPI ratio (after user-set UI scaling) relative to the standard 96 desktop DPI.
Widget build(BuildContext context) { final double clockHeight = clock.uiScale * 96 * 1.0; final double clockWidth = clockHeight * 3.5; final Map<String, bool> ledDisplay = _powerLedElements( clock.time.hour, clock.time.minute, clock.alarm?.hour, clock.alarm?.minute, ); List<String> activeLeds = []; for (String led in ledDisplay.keys) { if (ledDisplay[led] ?? false) { activeLeds.add(led); } } final Widget built = Stack( children: <Widget>[ for (String led in activeLeds) SvgPicture.asset('assets/images/led_segments/$led.svg', height: clockHeight, width: clockWidth), ], );
We have to account for users with OLED screens and potential burn-in. If OLED burn-in prevention setting is enabled, we'll slowly move the clock face in a circle with each revolution taking jiggleSpeed
minutes.
if (clock.oledJiggle) { // Do circular jiggle to avoid burn-in const double jiggleSpeed = 30.0; // Divides 60 cleanly. final double jiggle = clock.time.minute.toDouble() % jiggleSpeed; // At zero jiggle the LED display is at 9 o'clock. // As jiggle increases it does a full revolution CCW 0->29 / 30->59. // Jiggle is normalized to 0..2 radians. // Parametrically, x = r*cos(jiggle), y = r*sin(jiggle) // X and Y are -radius..radius const double pi = 3.141592; const double jiggleRadius = 7.0; final double t = jiggle / (jiggleSpeed / (2 * pi)); final double x = jiggleRadius*cos(t); final double y = jiggleRadius*sin(t); return SizedBox( height: clockHeight + jiggleRadius * 2, width: clockWidth + jiggleRadius * 2, child: Padding( padding: EdgeInsets.only( left: jiggleRadius - x, right: jiggleRadius + x, top: jiggleRadius - y, bottom: jiggleRadius + y, ), child: built, ), ); } else { return built; } } }
Solar clock face
This clock face is more algorithmical. It does not use predrawn graphics and relies on trigonometric analysis off user's location and current date. It uses Flutter's CustomPainter
class for drawing arcs and such.
Determining clock face size is more involved than with LED face. The optimum 2.5" height-width fits considerably less displays. build
returns a CustomPaint
with SolarGraphic
inheriting CustomPainter
.
Widget build(BuildContext context) { // Clockface is a square (for now) final double maximumSize = min( MediaQuery.sizeOf(context).height, MediaQuery.sizeOf(context).width); final double optimumSize = clock.uiScale * 96 * 2.5; final double clockSize = (maximumSize > optimumSize) ? optimumSize : maximumSize;
The graphic is derived from following data.
const SolarGraphic( this._nthDayOfYear, this._hours, this._mins, this._alarmH, this._alarmM, this._tzOffsetM, this._userLatitude, this._userLongitude, this._oledJiggle, );
Overriding paint
from CustomPainter
, we start off calculating how current time relates to UTC and to solar noon. With this calculated, we know where sun currently is in radians relative to user's zenith.
void paint(Canvas canvas, Size size) { const double pi = 3.141592; // Assuming perfectly circular orbit, solar noon is at 12.00 UTC on 0° E/W, // and each 15° added/removed is an hour. // Ie. at 23.75° E, sun is at 0° at 10.25 UTC, so offset is minus 2 hours and plus 25 minutes. final double sNoonOffset = -(_userLongitude / 15.0); final int sNoonOffsetH = sNoonOffset.floor(); final int sNoonOffsetM = ((sNoonOffset - sNoonOffsetH) * 60).round(); // Current time relative to solar noon. At 9.35 UTC, -60 minutes int hoursToSolarMinutes(int h) { return ((h - (_tzOffsetM ~/ 60)) - (12 + sNoonOffsetH)) * 60; } // +10 minutes -> -50 minutes int minutesToSolarMinutes(int m) { return (m - (_tzOffsetM % 60)) - (0 + sNoonOffsetM); } // -50 minutes -> -12.5° -> -pi/14.4 final double sunRadians = (hoursToSolarMinutes(_hours) + minutesToSolarMinutes(_mins)) / (12 * 60) * pi; // Same for alarm time instead of current time final double? alarmRadians = (_alarmH != null && _alarmM != null) ? (hoursToSolarMinutes(_alarmH) + minutesToSolarMinutes(_alarmM)) / (12 * 60) * pi : null;
To draw day-night separation on Earth, we need to know sun's current declination. This uses a well-known formula to approximate this. daynightRatio
is the ratio of the distance from earth's center to day-night line and from earth's center to earth's edge. So, it varies roughly between 0.0
and 0.2
. Margins and radiuses were chosen for aesthetic purposes.
// Calculating sun declination uses a well-known approximation // Day-night line & user dot are relative to earth radius. final double sunDeclination = -23.45 * cos((2 * pi / 365) * (_nthDayOfYear + 10)); final double dayNightRatio = sin(sunDeclination / 180 * pi); final double userDot = 1 - cos(_userLatitude / 180 * pi); double earthRadius = size.height * 0.3; double earthMargin = size.height * 0.2; double sunRadius = size.height * 0.05;
Since we're dealing with elements rotating concentrically, rotating the canvas makes drawing much simpler than starting to calculate circular geometry. If alarm is set, we start off with rotate for alarm outline, drawCircle
, and rotate for sun. Otherwise we just rotate straight for the sun.
if (alarmRadians != null) { canvas.translate(size.width * 0.5, size.height * 0.5); canvas.rotate(alarmRadians); canvas.translate(-size.width * 0.5, -size.height * 0.5); canvas.drawCircle( Offset(earthMargin + earthRadius, sunRadius + sunRadius * 0.15), sunRadius, Paint() ..color = Colors.white ..style = PaintingStyle.stroke, ); // avoid doing two sets of translation-rotations: canvas.translate(size.width * 0.5, size.height * 0.5); canvas.rotate(-alarmRadians + sunRadians); canvas.translate(-size.width * 0.5, -size.height * 0.5); } else { canvas.translate(size.width * 0.5, size.height * 0.5); canvas.rotate(sunRadians); canvas.translate(-size.width * 0.5, -size.height * 0.5); }
Sun is drawn filled. Earth only has an outline, so we have to draw the dayside separately.
// Sun canvas.drawCircle( Offset(earthMargin + earthRadius, sunRadius + sunRadius * 0.15), sunRadius, Paint() ..color = Colors.white ..style = PaintingStyle.fill, ); // Earth canvas.drawCircle( Offset(earthMargin + earthRadius, earthMargin + earthRadius), earthRadius, Paint() ..color = Colors.white ..style = PaintingStyle.stroke, );
We start off by drawing a filled half-circle pointing towards the sun. Then we draw either a day- or night-colored half-ellipse, which covers the area from center until the day-night line according to dayNightRatio
. If user has OLED burn-in prevention set, Paintingstyle.stroke
is used, and the day-night line is drawn in an arc instead of a filled ellipse.
const Color daySideColor = Color.fromARGB(255, 180, 180, 180); final PaintingStyle daySideFill = _oledJiggle ? PaintingStyle.stroke : PaintingStyle.fill; // Day side from which southernSolsticeRect is substituted from // or northernSolsticeRect added to final Rect daySideRect = Offset(earthMargin, earthMargin) & Size(earthRadius * 2, earthRadius * 2); canvas.drawArc( daySideRect, pi, pi, false, Paint() ..color = daySideColor ..style = daySideFill, ); final double ellipseHalfHeight = earthRadius * dayNightRatio; final bool dayIsLonger = sunDeclination >= 0.0 && _userLatitude >= 0.0 || sunDeclination < 0.0 && _userLatitude < 0.0; final Rect ellipseRect = Offset(earthMargin, earthMargin + (earthRadius - ellipseHalfHeight.abs())) & Size(earthRadius * 2, ellipseHalfHeight.abs() * 2); if (_oledJiggle) { canvas.drawArc( ellipseRect, dayIsLonger ? 0 : pi, pi, false, Paint() ..color = daySideColor ..style = PaintingStyle.stroke, ); } else { final Color ellipseColor = dayIsLonger ? daySideColor : Colors.black; canvas.drawOval( ellipseRect, Paint() ..color = ellipseColor ..style = PaintingStyle.fill, ); }
Finally we rotate the canvas to its final, original position and draw user's location on Earth.
// Now, let's rotate Earth & sun to correct time before adding user dot canvas.translate(size.width * 0.5, size.height * 0.5); canvas.rotate(-sunRadians); canvas.translate(-size.width * 0.5, -size.height * 0.5); // User dot canvas.drawCircle( Offset(earthMargin + earthRadius, earthMargin + earthRadius * userDot), size.width * 0.012, Paint() ..color = Colors.yellow ..style = PaintingStyle.fill, ); }
User drags left to edit settings
User sees options to set alarm time, location, clock face, OLED burn-in prevention and introductory text toggles, and UI scale slider,. Setting alarm uses Flutter's time picker, and both clock faces have a radioesque icon-button in horizontal list. Setting location opens a subscreen with a clickable world map + lat/long text fields.
Settings are governed by the SettingsController
, which is a caching abstraction for SettingsService
which abstracts SharedPreferences
. Saved settings include radio station URLs, clock face selection, alarm, location, OLED burn-in prevention, showing introductory texts, and UI scale. All but radio station URLs are modified via SettingsView
which the user now sees.
SettingsView.build
collects the individual settings widgets _alarm
, _clockFace
, _oled
, _intro
, and _uiScale
in a Column
widget.
child: Column( children: <Widget>[ Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: <Widget>[ _alarm(context), ], ), const Spacer(), _clockFace(context), const Spacer(), Row( children: <Widget>[ Expanded(child: _oled(context)), Expanded(child: _intro(context)), ], ), _uiScale(context), ], ),
The Column
widget is then wrapped in Padding
, Material
, ClipRRect
, ConstrainedBox
, Center
, and then finally Scaffold
.
User presses "No alarm set" / "Alarm is set" button
The button launches a showTimePicker
with 24h format enforced and updates the SettingsController
accordingly. The controller has separate conception of alarm time and if alarm is set, so that previously unset alarm time can be seen preset in the time picker.
Widget _alarm(BuildContext context) { return Row( children: <Widget>[ FilledButton.tonal( onPressed: () async { TimeOfDay? setAlarm = await showTimePicker( cancelText: 'Unset', confirmText: 'Set alarm', initialTime: settings.alarm, context: context, builder: (BuildContext context, Widget? child) { return MediaQuery( data: MediaQuery.of(context) .copyWith(alwaysUse24HourFormat: true), child: child!, ); }, ); if (setAlarm == null) { settings.updateAlarmSet(false); } else { settings.updateAlarmSet(true); settings.updateAlarm(setAlarm); } }, child: settings.alarmSet ? const Text('Alarm is set') : const Text('No alarm set'), ), ], ); }
Future<void> updateAlarmSet(bool newAlarmSet) async { if (newAlarmSet == _alarmSet) return; _alarmSet = newAlarmSet; notifyListeners(); await _settingsService.updateAlarmSet(false); }
Future<void> updateAlarmSet(bool alarmSet) async { await prefs.setBool('alarmSet', alarmSet); }
Future<void> updateAlarm(TimeOfDay? newAlarm) async { if (newAlarm == _alarm) return; notifyListeners(); // for backwards compatibility, null = alarm is not set if (newAlarm == null) { await _settingsService.updateAlarmSet(false); } else { await _settingsService.updateAlarmH(newAlarm.hour); await _settingsService.updateAlarmM(newAlarm.minute); _alarm = newAlarm; await _settingsService.updateAlarmSet(true); } }
Future<void> updateAlarmH(int alarmH) async { await prefs.setInt('alarmH', alarmH); }
Future<void> updateAlarmM(int alarmM) async { await prefs.setInt('alarmM', alarmM); }
User selects a clock face
The clock face buttons use an InkWell
that updates the clock face on tap. Each clock face is hardcoded as its own widget tree as there are only a few.
Widget _clockFace(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: <Widget>[ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(16.0)), child: Material( child: Ink.image( fit: BoxFit.contain, width: 100, height: 100, image: platformAwareImageProvider('assets/images/ledclock.png'), child: InkWell( onTap: () { settings.updateClockFace(ClockFace.led); }, ), ), ), ), Column( children: <Widget>[ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(16.0)), child: Material( child: Ink.image( fit: BoxFit.cover, width: 100, height: 100, image: platformAwareImageProvider( 'assets/images/solarclock.png'), child: InkWell( onTap: () { settings.updateClockFace(ClockFace.solar); }, ), ), ), ), _location(context), ], ), ], ); }
Flutter's web builds differ from native builds asset-wise. We need to use NetworkImage
on web and AssetImage
on native builds. This is done via platformAwareImageProvider
shim.
ImageProvider platformAwareImageProvider(String asset, {String? package}) { if (kIsWeb) { return NetworkImage( scale: 1.0, 'assets/${package == null ? '' : 'packages/$package/'}$asset', ); } return AssetImage( asset, package: package, ); }
The clock face is then updated in SettingsController
.
Future<void> updateClockFace(ClockFace? newClockFace) async { if (newClockFace == null) return; if (newClockFace == _clockFace) return; _clockFace = newClockFace; notifyListeners(); await _settingsService.updateClockFace(newClockFace); }
Future<void> updateClockFace(ClockFace face) async { await prefs.setString('clockFace', face.name); }
User presses location button
The location button is only enabled when the solar clock face is selected. It is then a clockface-specific setting as it does not make sense with the digital clockface. After pressing the button user is moved to LocationView
.
Widget _location(BuildContext context) { return FilledButton.tonal( onPressed: (settings.clockFace == ClockFace.solar) ? () { Navigator.restorablePushNamed(context, LocationView.routeName); } : null, child: const Text('Location'), ); }
LocationView
will span all available screen area to allow better ergonomics setting location by clicking the map projection. _userDot
widget paints the currently set location on the Ink.image
presenting the world map. Tapping on the Ink.image
will update set location and the TextField
widgets controlled by the respective TextEditingController
classes.
body: Builder( builder: (BuildContext context) { return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(16.0)), child: Material( child: Stack( children: <Widget>[ LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { return _userDot( constraints.maxWidth, constraints.maxHeight); }), Ink.image( fit: BoxFit.fill, image: platformAwareImageProvider( 'assets/images/worldmap.png'), child: InkWell( onTapUp: (TapUpDetails details) { _updateLocation(details, context.size); textLat.text = settings.latitude.toString(); textLong.text = settings.longitude.toString(); }, ), ), ], ), ), ); }, ), ); },
The TextField
widgets show current latitude and longitude, and allow setting a new location. They require some input sanitization logic, but can update settings directly rather than deriving the numbers via _updateLocation
. The _userDot
moves after each input.
title: Row( children: <Widget>[ const Text('Latitude: '), Expanded( child: TextField( controller: textLat, onChanged: (String value) { if (value == '') { settings.updateLatitude(0.0); } else { try { double latitude = double.parse(value); if (latitude > 90.0) { latitude = 90.0; } else if (latitude < -90.0) { latitude = -90.0; } settings.updateLatitude(latitude); } catch (e) { settings.updateLatitude(0.0); } } }, ), ), const Text('Longitude: '), Expanded( child: TextField( controller: textLong, onChanged: (String value) { if (value == '') { settings.updateLongitude(0.0); } else { try { double longitude = double.parse(value); if (longitude > 180.0) { longitude = 180.0; } else if (longitude < -180.0) { longitude = -180.0; } settings.updateLongitude(longitude); } catch (e) { settings.updateLongitude(0.0); } } }, ), ), ], ), ),
Future<void> updateLongitude(double? newLongitude) async { if (newLongitude == null) return; if (newLongitude == _longitude) return; _longitude = newLongitude; notifyListeners(); await _settingsService.updateLongitude(newLongitude); }
Future<void> updateLongitude(double longitude) async { await prefs.setDouble('longitude', longitude); }
Future<void> updateLatitude(double? newLatitude) async { if (newLatitude == null) return; if (newLatitude == _latitude) return; _latitude = newLatitude; notifyListeners(); await _settingsService.updateLatitude(newLatitude); }
Future<void> updateLatitude(double latitude) async { await prefs.setDouble('latitude', latitude); }
To make changes via keyboard sensical, we need to move the cursor to the end of the TextField
after each rebuild. The widgets are rebuilt after each character input.
Widget build(BuildContext context) { textLat.text = settings.latitude.toString(); textLat.selection = TextSelection.collapsed(offset: textLat.text.length); textLong.text = settings.longitude.toString(); textLong.selection = TextSelection.collapsed(offset: textLong.text.length);
- User taps on the world map & dot is drawn from set location
The problem gets more interesting here. We need to derive latitude and longitude from X and Y coordinates on an arbitrarily sized map projection. Using an equirectangular projection makes this problem graspable, as it allows a mere linear conversion without making users' life more difficult.
The chosen equirectangular map projection is 853 units width. The dateline is not at the edge, but rather at 829.25 units which then means a 23.75 unit offset (and so Greenwich or 0° longitude is at 853/2-23.75 units).
onTapUp (TapUpDetails details)
fromInkWell
has given us the X and Y coordinates (tapUp happens only when no other gesture has overridden the tap) andcontext
the width and height of the map area. While longitude requires the dateline offset and accounting for overflowing 180°+ behind the dateline, latitude is a simple linear relationship.void _updateLocation(TapUpDetails details, Size? mapWidgetSize) { final double initialLongitude = details.localPosition.dx; final double initialLatitude = details.localPosition.dy; // The date line does not go exactly at map's edges: Greenwich // is about 1/40 of the map's width left of center. const double longitudeOffset = 23.75 / 853; if (mapWidgetSize != null) { double overflowingLongitude = ((initialLongitude + longitudeOffset * mapWidgetSize.width) / mapWidgetSize.width * 360 - 180); if (overflowingLongitude > 180) { overflowingLongitude -= 360; } final double longitude = overflowingLongitude; final double latitude = (initialLatitude / mapWidgetSize.height * 180 - 90) * (-1); settings.updateLatitude((latitude * 100).roundToDouble() / 100); settings.updateLongitude((longitude * 100).roundToDouble() / 100); } }
Drawing the user's location then requires doing this in reverse. The location is stored as latitude and longitude, and
CustomPaint
requires X and Y coordinates.Widget _userDot(double w, double h) { // Location to x and y, ie. an inverse of _updateLocation const double longitudeOffset = 23.75 / 853; double x = ((settings.longitude + 180) / 360 * w - longitudeOffset * w); double y = ((-settings.latitude) + 90) / 180 * h; return CustomPaint( painter: DotGraphic( x, y, ), size: Size(w, h), ); } } @immutable class DotGraphic extends CustomPainter { const DotGraphic( this._x, this._y, ); final double _x; final double _y; @override void paint(Canvas canvas, Size size) { canvas.drawCircle( Offset(_x, _y), size.width * 0.0025, Paint() ..color = Colors.yellow ..style = PaintingStyle.fill, ); } @override bool shouldRepaint(DotGraphic oldDelegate) { return oldDelegate._x != _x || oldDelegate._y != _y; } }
User edits miscellaneous settings
The miscellaneous settings include OLED burn-in prevention, showing introductory texts, and setting UI scale. The burn-in prevention is a simple toggle.
Widget _oled(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: <Widget>[ const Text('Prevent OLED burn'), Switch.adaptive( onChanged: (x) { if (settings.oled) { settings.updateOled(false); } else { settings.updateOled(true); } }, value: settings.oled, ), ], ); }
Future<void> updateOled(bool? newOled) async { if (newOled == null) return; if (newOled == _oled) return; _oled = newOled; notifyListeners(); await _settingsService.updateOled(newOled); }
Future<void> updateOled(bool oled) async { await prefs.setBool('oled', oled); }
The introductory text toggle is identical.
Widget _intro(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: <Widget>[ const Text('Show intro texts'), Switch.adaptive( onChanged: (x) { if (settings.intro) { settings.updateIntro(false); } else { settings.updateIntro(true); } }, value: settings.intro, ), ], ); }
Future<void> updateIntro(bool? newIntro) async { if (newIntro == null) return; if (newIntro == _intro) return; _intro = newIntro; notifyListeners(); await _settingsService.updateIntro(newIntro); }
Future<void> updateIntro(bool intro) async { await prefs.setBool('intro', intro); }
The UI scale uses a Slider
that sets the scaling ratio from 1.0 to 2.0. The upper limit could be set higher, but as a design decision it's kept as low as is sensible.
Widget _uiScale(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text('Clock and menu scale'), // Slider.adaptive is not showing up on mobile Safari 15 Slider( divisions: 10, min: 1.0, max: 2.0, value: settings.uiScale ?? 1.0, onChanged: (double x) => settings.updateUIScale(x), ), ], ); } }
Future<void> updateUIScale(double? newUIScale) async { if (newUIScale == null) return; if (newUIScale == _uiScale) return; _uiScale = newUIScale; notifyListeners(); await _settingsService.updateUIScale(newUIScale); }
Future<void> updateUIScale(double newUIScale) async { await prefs.setDouble('uiScale', newUIScale); } }
User drags right to edit radio
User sees the currently selected radio station in a dropdown of favorite stations. They can remove stations and add new ones. Ideally the view would have an engaging way to present all radio stations available, but the current dropdown and manual URL adding provides a good enough foundation. The view essentially has a ListView
with a headline and two widgets _selectFavoriteStation
and _addStation
.
return ListView( shrinkWrap: true, children: <Widget>[ const Align( alignment: Alignment.center, child: Text('Radio deck')), Padding( padding: const EdgeInsets.all(8), child: _selectFavoriteStation(context), ), Padding( padding: const EdgeInsets.all(8), child: _addStation(context), ), ], );
Audio functionality is implemented using media_kit
library, which provides a straightforward-to-use Player
class. RadioService
can be then implemented as a thin wrapper. Player.state.duration
is used as heuristic to see if there anything playable actually playing.
class RadioService { RadioService(this._player); final Player _player; factory RadioService.create() { final player = Player(); // Add any Player debugging & setup here return RadioService(player); } void selectStream(String path) async { await _player.open(Media(path), play: false); await _player.setPlaylistMode(PlaylistMode.loop); } void play() async { await _player.play(); } void stop() async { await _player.stop(); } bool isPlaying() { return _player.state.playing && _player.state.duration != Duration.zero; } }
RadioController
is similarly thin. It does implement a failsafe protocol, where a bundled ping sound is played if RadioService
is not playing anything after 5 seconds.
class RadioController { RadioController(this._radioService, this._settings); final RadioService _radioService; final SettingsController _settings; Timer? initiateFailsafe; factory RadioController.create(SettingsController settingsController) { return RadioController(RadioService.create(), settingsController); } void play() { if (isPlaying()) { stop(); } _radioService.selectStream(_settings.radioStation); _radioService.play(); initiateFailsafe = Timer(const Duration(milliseconds: 5000), failsafeStream); } void stop() { _radioService.stop(); initiateFailsafe?.cancel(); } void toggle() { if (isPlaying()) { stop(); } else { play(); } } bool isPlaying() { return _radioService.isPlaying(); } void failsafeStream() { if (isPlaying() == false) { _radioService.selectStream(SettingsController.fallbackStation); _radioService.play(); } } }
User adds a radio station
Adding radio station works by inputting an URL. A DropdownButton
is used to allow different methods in the future, but could be omitted for the present version. Since we have to account for both user pressing enter in the TextField
and tapping on the button, we need onSubmitted
and onPressed
callbacks respectively. A TextEditingController
is needed for the onPressed
callback.
Widget _addStation(BuildContext context) { TextEditingController addRadioController = TextEditingController(); return Column( children: <Widget>[ Row( children: <Widget>[ const Text('Add a new station: '), Expanded( child: DropdownButton<String>( onChanged: (String? str) {}, value: 'Custom', isExpanded: true, items: const <DropdownMenuItem<String>>[ DropdownMenuItem( value: 'Custom', child: Text('Radio station URL')), ], ), ), ], ), Row( children: <Widget>[ const Text('URL: '), Expanded( child: TextField( controller: addRadioController, onSubmitted: (String value) { if (value == '') { return; } else { settings.addRadioStation(value); } }, ), ), FilledButton.tonal( onPressed: () { if (addRadioController.text == '') { return; } else { settings.addRadioStation(addRadioController.text); } }, child: const Text('Add'), ), ], ), ], ); } }
Future<void> addRadioStation(String? newRadioStation) async { if (newRadioStation == null) return; if (newRadioStation == _radioStation) return; _radioStation ??= newRadioStation; await _settingsService.addRadioStation(newRadioStation); _radioStations = await _settingsService.radioStations(); notifyListeners(); }
Future<void> addRadioStation(String path) async { final List<String>? paths = prefs.getStringList('radioStations'); if (paths == null) { await prefs.setStringList('radioStations', [path]); } else { if (paths.contains(path)) { return; } else { paths.add(path); await prefs.setStringList('radioStations', paths); } } }
User changes and removes a station
These interactions are bound to the _selectFavoriteStation
widget. In addition to using SettingsController.updateRadioStation
and SettingController.removeRadioStation
methods, the user expects for the currently playing radio station to match whatever is selected in DropdownButton
. This requires some heuristics both when changing station and when removing the [currently selected] station.
Widget _selectFavoriteStation(BuildContext context) { return Row( children: <Widget>[ const Text('Select a station: '), Expanded( child: DropdownButton<String>( value: settings.radioStation, onChanged: (String? path) { settings.updateRadioStation(path); if (radio.isPlaying()) { radio.stop(); radio.play(); } }, isExpanded: true, items: settings.radioStations.map<DropdownMenuItem<String>>( (String station) { return DropdownMenuItem( value: station, child: Text(station), ); }, ).toList(), ), ), FilledButton.tonal( onPressed: settings.radioStation != SettingsController.fallbackStation ? () async { await settings.removeRadioStation(settings.radioStation); if (radio.isPlaying()) { radio.stop(); radio.play(); } } : null, child: const Text('Remove'), ), ], ); }
Future<void> updateRadioStation(String? newRadioStation) async { if (newRadioStation == null) return; if (newRadioStation == _radioStation) return; _radioStation = newRadioStation; notifyListeners(); await _settingsService.updateRadioStation(newRadioStation); }
Future<void> updateRadioStation(String path) async { await prefs.setString('radioStation', path); }
Future<void> removeRadioStation(String? oldRadioStation) async { if (oldRadioStation == null) return; await _settingsService.removeRadioStation(oldRadioStation); _radioStations = await _settingsService.radioStations(); if (_radioStation == oldRadioStation) { if (_radioStations != null) { _radioStation = _radioStations?.first; } else { _radioStation = null; } updateRadioStation(_radioStation); } notifyListeners(); }
Future<void> removeRadioStation(String path) async { final List<String>? paths = prefs.getStringList('radioStations'); if (paths == null) { return; } else { paths.removeWhere((element) => element == path); if (paths.isEmpty) { await prefs.remove('radioStations'); await prefs.remove('radioStation'); return; } else { await prefs.setStringList('radioStations', paths); return; } } }
When no radio stations are saved, SettingsController.radioStation
and .radioStations
return a fallback value SettingsController.fallbackStation
.
String get radioStation => _radioStation ?? fallbackStation; List<String> get radioStations => _radioStations ?? [fallbackStation];
static const String fallbackStation = kIsWeb ? 'assets/assets/sounds/ping.mp3' : 'assets/sounds/ping.mp3';
User taps to play radio back in clock view
Tapping triggers RadioController.toggle()
, which starts/stops RadioService
and thus Player
from media_kit
.
onTap: () { radio.toggle(); },