Implementing a lotto retrospective app
source link: https://www.flutterclutter.dev/flutter/tutorials/implementing-a-lotto-retrospective-app/2020/1996/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Implementing a lotto retrospective app
Sometimes I wonder, if I would have won a lot of money if I had played lotto. Being a rational being I have never done that. But the curious side of me wants to know, just in case, if it was a mistake to make such a decision based on mathematical probability.
In this tutorial, we are going to implement an app that lets the user choose his lucky numbers and tells him whether he would have won anything if he had played with these numbers every week in the UK lotto.
We are going to do that in four steps:
- Build the UI
- Implement the BLoC
- Implement the service that encapsulates the data
- Gather the lotto data
By using the BLoC pattern, we can clearly separate the steps, which also makes an explanation much easier. Since there is no dependency between the UI and the BLoC, we could change the order of the steps the way we wanted.
Build the UI
The central element of our UI is the one the user can interact with: that’s the “lottery ticket” on which the user is able to cross the numbers which he wants to test regarding the prize he would have won.
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class LottoInput extends StatelessWidget { @override Widget build(BuildContext context) { return GridView.builder( padding: EdgeInsets.all(2), physics: NeverScrollableScrollPhysics(), shrinkWrap: true, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 9, crossAxisSpacing: 4.0, mainAxisSpacing: 4.0, ), itemCount: 59, itemBuilder: (context, index) { return Container( decoration: BoxDecoration( border: Border.all( color: Colors.green, width: 2 ), shape: BoxShape.circle, ), child: Center( child: Text( (index + 1).toString(), style: TextStyle( fontSize: 16, color: Colors.green, fontWeight: FontWeight.bold ), ) ), ); }, ); } }
In the UK, there is the 6/59 format which means that you cross 6 numbers of a set of numbers from 1 to 59. These numbers are compared with the current winning numbers. Depending on what’s the intersection of both sets, you get a different prize (or none).
So using a GridView
, we build this number block. We want it to have a fixed amount of rows (9 in this case, defined by crossAxisCount
) and spacing of 4 pixels in between. We achieve this by using a SliverGridDelegateWithFixedCrossAxisCount
as gridDelegate
.
The index starts with 0, that’s why we display (index + 1).toString()
.
While that’s a great start, we have no interactivity yet. That’s okay, because we are going to implement that once the BLoC is ready. But what we can do already, is preparing the UI for the time when there is a state that the view receives from the BLoC.
class LottoInput extends StatelessWidget { LottoInput({ this.crossed = const [] }); final List<int> crossed; ... }
First we add a constructor parameter determining the numbers that were chosen. We call this array crossed
.
class LottoInput extends StatelessWidget { ... Stack _getNumber(int index) { return Stack( children: [ Center( child: Text( '${index + 1}', style: TextStyle( fontSize: 16, color: Colors.green, fontWeight: FontWeight.bold ), ) ), Center( child: CustomPaint( painter: CrossPainter(), size: crossed.contains(index + 1) ? Size.infinite : Size.zero ) ) ] ); } } class CrossPainter extends CustomPainter { CrossPainter({ this.strokeWidth = 4, this.color = Colors.blueGrey }); int strokeWidth; Color color; @override void paint(Canvas canvas, Size size) { _drawCrosshair(canvas, size); } void _drawCrosshair(Canvas canvas, Size size) { Paint crossPaint = Paint() ..strokeWidth = strokeWidth / 2 ..color = color; double crossSize = size.longestSide / 1.8; canvas.drawLine( size.center(Offset(-crossSize, -crossSize)), size.center(Offset(crossSize, crossSize)), crossPaint ); canvas.drawLine( size.center(Offset(crossSize, -crossSize)), size.center(Offset(-crossSize, crossSize)), crossPaint ); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return true; } }
Then we use this list to decide whether a cross should be display on top of the respective number. The cross itself is drawn by using a CustomPainter
. We use the center of the drawn number to calculate the measurements of the cross and overdraw it a bit.
To make it only appear when the number is provided, we set the size of the CustomPaint
to 0 when the crossed
list does not contain the current number.
If we now provide a crossed
list that actually contains some numbers to the widget, we get something like this:
Now it’s time to embed this input widget into another widget that looks better and contains some instructions for the user:
import 'package:flutter_lotto_retrospection/views/lotto_details.dart'; import 'package:flutter/material.dart'; import 'lotto_input.dart'; class Home extends StatefulWidget { @override _HomeState createState() => _HomeState(); } class _HomeState extends State<Home> { @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.green, body: SingleChildScrollView( padding: EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.max, children: <Widget>[ Image.asset('assets/clover_white.png', width: 80), SizedBox(height: 24), Text( 'Lotto retrospective', style: TextStyle( color: Colors.white, shadows: [ Shadow( color: Colors.black26, offset: Offset(1.5, 1.5), blurRadius: 10 ) ], fontSize: 26 ) ), SizedBox(height: 24), Text( 'How would your numbers have performed in 2020?', style: TextStyle( fontSize: 18 ), textAlign: TextAlign.center, ), SizedBox(height: 24), _getLottoNumbersInput(), ], ) ) ); } Container _getLoadingIndicator() { return Container( child: Center( child: Text('Loading ...') ), ); } Container _getLottoNumbersInput() { return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(32)), boxShadow: [ BoxShadow( offset: Offset(2,2), blurRadius: 6, spreadRadius: 1, color: Colors.black26 ) ] ), padding: EdgeInsets.all(24), alignment: Alignment.center, child: Column( children: [ Text( 'Choose your lucky numbers:', style: TextStyle( fontSize: 18, color: Colors.black87 ) ), SizedBox(height: 16), SizedBox( width: 300, child: LottoInput() ) ], ), ); } }
To make it look appealing, we choose a green background and add a clover as a symbol for luck. We also add some spacings and paddings as well as an explanatory text.
To make every text appear in white without further configuration, we set these values in the MaterialApp
‘s ThemeData
, too:
class LottoRetrospective extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.green, visualDensity: VisualDensity.adaptivePlatformDensity, textTheme: TextTheme( bodyText1: TextStyle( color: Colors.white ), bodyText2: TextStyle( color: Colors.white ), caption: TextStyle( color: Colors.white ), ), accentColor: Colors.white, backgroundColor: Colors.green ), home: Scaffold( body: SafeArea( child: Home(), ), ), ); } }
This way, we don’t always have to set the color explicitly to white when we add a text. Also, the borders of OutlineButton
s are automatically in the correct color.
Okay, now we have an app in which we have the numbers from 1 to 59. We’re still lacking actual interactivity, though. We’re still working with a static list of crossed numbers. That’s where the BLoC pattern comes into play.
Implement the BLoC
We’ll be using the BLoC from the flutter bloc library. This is going to save us a lot of work. The concept of this pattern itself is not that complicated. But we don’t need to reinvent the wheel. Actually, there are even plugins for the IDEs to automatically create the bloc class, events and states.
There are three parts we need to implement:
- The BLoC: That’s where the actual business logic resides
- The events: They represent an action triggered by the user inside of the view (e. g. tapping a button)
- The states: This is what the BLoC sends back to the UI after it has processed an event
Let’s start with a BLoC that contains no logic:
import 'dart:async'; import 'package:meta/meta.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; part 'lotto_event.dart'; part 'lotto_state.dart'; class LottoBloc extends Bloc<LottoEvent, LottoState> { @override Stream<LottoState> mapEventToState( LottoEvent event, ) async* {} }
Okay, like it was mentioned above, the BLoC receives events and turns them into states. Let’s continue implementing the events.
What kind of events do we have? Actually, there are only two user interactions that trigger some business logic:
- Starting the app
- Tapping a number
Starting the app would load the historical lotto data whilst displaying a loading indicator. Tapping a number would select or deselect a number which makes it part of the “lucky numbers” or removes it from the selection.
part of 'lotto_bloc.dart'; abstract class LottoEvent extends Equatable { const LottoEvent(); } class Initialize extends LottoEvent { @override List<Object> get props => []; } class CrossNumber extends LottoEvent { CrossNumber({ this.number }); final int number; @override List<Object> get props => [number]; }
The Initialize
event has no properties, while the CrossNumber
event takes the number that was just tapped.
Last but not least, we’re creating the LottoState
:
part of 'lotto_bloc.dart'; class LottoState extends Equatable { LottoState({ @required this.currentNumbers, @required this.searchResult, @required this.initialized }); final List<int> currentNumbers; final LottoHistorySearchResult searchResult; final bool initialized; LottoState copyWith(List<int> currentNumbers, LottoHistorySearchResult searchResult, bool initialized) { return new LottoState( currentNumbers: currentNumbers, searchResult: searchResult, initialized: initialized ); } @override List<Object> get props => [currentNumbers, searchResult, initialized]; } class LottoInitial extends LottoState { LottoInitial(): super( currentNumbers: [], searchResult: LottoHistorySearchResult(), initialized: false ); } class LottoHistorySearchResult extends Equatable { final int match6count; final int match5withBonusCount; final int match5count; final int match4count; final int match3count; final int match2count; final int prizeSum; final int costs; final int gameCount; LottoHistorySearchResult({ this.match6count = 0, this.match5withBonusCount = 0, this.match5count = 0, this.match4count = 0, this.match3count = 0, this.match2count = 0, this.prizeSum = 0, this.costs = 0, this.gameCount }); @override List<Object> get props => [ match6count, match5withBonusCount, match5count, match4count, match3count, match2count, prizeSum, gameCount ]; }
The lotto state holds currentNumbers
which is the list of numbers the user has chose in the UI, initialized
, which is initially false and becomes true when the Initialize
event was processed and emitted a state and searchResult
being a model that holds all the information that is relevant for the user after choosing the numbers.
What information do we want to display for the user?
The most important is prizeSum
. This is the total sum of prizes he won with the chosen numbers. But the user may wants to see further details like: “How do my prizes add up?”. That’s why we also want to show the amount of different matches (6, 5, 5 with bonus, 4, 3, 2). Also, the total numbers of games played could be interesting.
Implementing a service
For the BLoC to actually perform some business logic, we better extract the main business functionality into a separate service. That’s the service that fetches the historical lotto data and returns well-formed data. Later on, this will be real data, but yet we can just return some mock data.
import 'package:meta/meta.dart'; class LottoHistoryService { List<LottoResult> _cachedList; Future<List<LottoResult>> getHistoricalResults() async { return [ LottoResult( prizes: Prizes( match6: Prize(amount: 7000000), match5plusBonus: Prize(amount: 1000000), match5: Prize(amount: 1000000), match4: Prize(amount: 1750), match3: Prize(amount: 140), match2: Prize(amount: 0), ), dateTime: DateTime.parse('2020-10-14'), correctNumbers: CorrectNumbers( numbers: [1, 14, 33, 44, 47, 56], bonus: 6 ) ) ]; } } class LottoResult { Prizes prizes; DateTime dateTime; CorrectNumbers correctNumbers; LottoResult({ @required this.prizes, @required this.dateTime, @required this.correctNumbers }); } class Prizes { Prize match6; Prize match5plusBonus; Prize match5; Prize match4; Prize match3; Prize match2; Prizes({ @required this.match6, @required this.match5plusBonus, @required this.match5, @required this.match4, @required this.match3, @required this.match2, }); } class Prize { int amount; int winners; Prize({ @required this.amount, this.winners = 0 }); } class CorrectNumbers { @required List<int> numbers; @required int bonus; CorrectNumbers({ this.numbers, this.bonus }); }
We have created models for all of our data. This is much cleaner than working with Map
s and List
s because we can directly access the properties and ensure they are set. A LottoResult
represents one lotto game. It has a date, a list of prizes and of course the correct numbers. Our only public method getHistoricalResults()
returns a List
of these.
Putting things together
Now the BLoC needs to have this service injected and call it during initialization.
class LottoBloc extends Bloc<LottoEvent, LottoState> { LottoBloc({ @required this.lottoHistoryService }) : super(LottoInitial()); final LottoHistoryService lottoHistoryService; List<LottoResult> _lottoHistoryResults = []; @override Stream<LottoState> mapEventToState( LottoEvent event, ) async* { if (event is Initialize) { yield await _mapInitializeToState(); } } Future<LottoState> _mapInitializeToState() async { _lottoHistoryResults = await lottoHistoryService.getHistoricalResults(); return LottoState( currentNumbers: state.currentNumbers, searchResult: state.searchResult, initialized: true ); } }
So once the Initialize
event is received, the historic lotto results are fetched from the service. Its execution is awaited and afterwards a state is emitted with initialized
set to true
. The purpose is that we can display a loading indicator as long as the initialization takes place which we dismiss once there is a LottoState
with initialized
set to true
.
Now we can properly react on the NumberCrossed
event because we now have data we can work on:
Stream<LottoState> _mapCrossNumberToState(CrossNumber event) async* { List<int> newNumbersList = List.from(state.currentNumbers); if (state.currentNumbers.contains(event.number)) { newNumbersList.remove(event.number); yield state.copyWith(newNumbersList, LottoHistorySearchResult(), true); return; } if (newNumbersList.length >= 6) { return; } newNumbersList.add(event.number); yield state.copyWith(newNumbersList, state.searchResult, true); LottoHistorySearchResult newResult = _calculateSearchResult(newNumbersList, _lottoHistoryResults/*await lottoHistoryService.getHistoricalResults()*/); yield state.copyWith(newNumbersList, newResult, true); } LottoHistorySearchResult _calculateSearchResult(List<int> numbers, List<LottoResult> results) { Set<int> numbersAsSet = Set.of(numbers); int match6count = 0; int match5withBonusCount = 0; int match5count = 0; int match4count = 0; int match3count = 0; int match2count = 0; int prizeSum = 0; int gameCount = results.length; for(LottoResult result in results) { Set winningNumbers = Set.of(result.correctNumbers.numbers); int correctNumbersCount; correctNumbersCount = numbersAsSet.intersection(winningNumbers).length; if (correctNumbersCount == 6) { prizeSum += result.prizes.match6.amount; match6count += 1; } if (correctNumbersCount == 5) { if (state.currentNumbers.contains(result.correctNumbers.bonus)) { match5withBonusCount += 1; } else { prizeSum += result.prizes.match5.amount; match5count += 1; } } else if (correctNumbersCount == 4) { prizeSum += result.prizes.match4.amount; match4count += 1; } else if (correctNumbersCount == 3) { prizeSum += result.prizes.match3.amount; match3count += 1; } else if (correctNumbersCount == 2) { prizeSum += result.prizes.match2.amount; match2count += 1; } } return LottoHistorySearchResult( match6count: match6count, match5withBonusCount: match5withBonusCount, match5count: match5count, match4count: match4count, match3count: match3count, match2count: match2count, prizeSum: prizeSum, costs: results.length * 2, gameCount: gameCount ); }
When the number that is attached to the NumberPressed
event, is already present in the currentNumbers
of the current state, it will be removed. That’s because we want the user to be able toe deselect numbers when tapping a number that is selected.
If there are already 6 numbers selected, nothing should happen.
Otherwise, the new LottoHistorySearchResult
should be calculated. In the body of this method we just count the matches and prize sum. We calculate the costs by multiplyling the total number of games by 2, assuming a lottery ticket is 2 £.
Okay what do we have so far?
- A UI displaying static content
- A BLoC that is capable of handling events from the UI and emitting states
- A service that provides (yet static) lotto data
Next step is to make the UI actually use the bloc, send events to it and react to state changes:
home: BlocProvider( create: (BuildContext context) { return LottoBloc( lottoHistoryService: LottoHistoryService() ); }, child: Scaffold( body: SafeArea( child: Home(), ), ) ),
First, we wrap the Home
widget with a BlocProvider
. That’s necessary for us to be able to use context.bloc<LottoBloc>()
to access the bloc from within our widget.
class _HomeState extends State<Home> { LottoBloc bloc; final numberFormat = new NumberFormat("#,##0", "en_GB"); @override void didChangeDependencies() { bloc = context.read(); super.didChangeDependencies(); } @override void initState() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { bloc.add(Initialize()); }); super.initState(); } @override void dispose() { bloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder<LottoBloc, LottoState>( builder: (BuildContext context, LottoState state) { return Scaffold( backgroundColor: Colors.green, body: _getBody(state) ); }, ); } Widget _getBody(LottoState state) { if (!state.initialized) { return _getLoadingIndicator(); } return _getMainWidget(state); }
Next we make the LottoBloc a member variable and assign it in the didChangeDependencies()
method. After the first rendered frame, we add the Initialize
event to our bloc.
In our widget hierarchy, we react on the state being initialized. If it’s not, we show a loading indicator, otherwise we show the usual content.
Container _getLoadingIndicator() { return Container( child: Center( child: Text('Loading ...') ), ); }
The loading indicator is just a centered text showing “Loading …”.
Everything else stays the same like before, expect for two details:
- We pass
state.currentNumbers
to ourLottoInput
widget - We show the results to the user
The results are the costs and the prize presented in text form in a Column
:
Widget _getResultTexts(LottoState state) { if (state.searchResult == null) { return Container(); } return Column( children: [ SizedBox(height: 24), Text( 'Costs: ${numberFormat.format(state.searchResult.costs)} £', style: TextStyle( fontSize: 20, color: Colors.white ), textAlign: TextAlign.center, ), SizedBox(height: 8), Container( padding: EdgeInsets.all(8), child: Text( 'Prize: ${numberFormat.format(state.searchResult.prizeSum)} £ (and ${state.searchResult.match2count} free tickets)', style: TextStyle( fontSize: 20, ), textAlign: TextAlign.center, ) ) ] ); }
Now if we start the app and input the static numbers we defined in the service, we get information about our costs (in this case 2 £ because our static result only contains one game) and a prize of 7,000,000 £ because we have 6 matches:
We enhance the view by a “details” button that gives the user more insight about the game results:
OutlineButton( shape: new RoundedRectangleBorder(borderRadius: new BorderRadius.circular(32.0)), onPressed: () { setState(() { showDialog( context: context, builder: (BuildContext context) { return LottoDetails(state: state); } ); }); }, borderSide: BorderSide( color: Colors.white, width: 2 ), child: Text( 'DETAILS', style: TextStyle( color: Colors.white ), ) )
We use the showDialog()
method to show the details in an overview. This is encapsulated in its own widget:
import 'package:flutter/material.dart'; import 'package:flutter_lotto_retrospection/bloc/lotto_bloc.dart'; class LottoDetails extends StatelessWidget{ LottoDetails({ this.state }); final LottoState state; @override Widget build(BuildContext context) { if (state == null) { return Container(); } return AlertDialog( backgroundColor: Colors.green, shape: RoundedRectangleBorder( borderRadius: BorderRadius.all( Radius.circular(8.0) ) ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( 'Game details', style: TextStyle( fontSize: 24, color: Colors.white, ) ), SizedBox(height: 24), _getTextRow('Games played:\n${state.searchResult.gameCount}'), _getTextRow('Games with 2 matches:\n${state.searchResult.match2count}'), _getTextRow('Games with 3 matches:\n${state.searchResult.match3count}'), _getTextRow('Games with 4 matches:\n${state.searchResult.match4count}'), _getTextRow('Games with 5 matches:\n${state.searchResult.match5count}'), _getTextRow('Games with 5 matches and bonus:\n${state.searchResult.match5withBonusCount}'), _getTextRow('Games with 6 matches:\n${state.searchResult.match6count}'), SizedBox(height: 24), OutlineButton( onPressed: () { Navigator.of(context).pop(); }, shape: new RoundedRectangleBorder(borderRadius: new BorderRadius.circular(32.0)), borderSide: BorderSide( color: Colors.white, width: 2 ), child: Text( 'OK', style: TextStyle( color: Colors.white ), ) ), ] ) ); } Text _getTextRow(String text) { return Text( text, style: TextStyle( color: Colors.white ), textAlign: TextAlign.center, ); } }
Displaying actual lottery data
Yet, we only display made up data, which makes the whole app completely useless. Let’s fetch some actual lottery data instead.
The lottery website of the UK has an archive of lottery data.
With a simple Javascript we put all the data from the DOM in a useful JSON format:
function getMonthFromString(mon){ var month = new Date(Date.parse(mon +" 1, 2012")).getMonth()+1; return month <= 9 ? '0' + month : month; } function getDayFromString(day){ return day <= 9 ? '0' + day : day; } results = []; document.querySelectorAll('#siteContainer > div.main > table > tbody > tr').forEach(function(element) { let matches = element.innerText.match(/(.+) (\d{1,2})\w{1,2} (.+) (.+)\s+(\d+) (\d+) (\d+) (\d+) (\d+) (\d+) (\d+)\s+£(.+)/); let data = { date: matches[4] + '-' + getMonthFromString(matches[3]) + '-' + getDayFromString(matches[2]), month: getMonthFromString(matches[3]), year: parseInt(matches[4]), numbers: [ parseInt(matches[5]), parseInt(matches[6]), parseInt(matches[7]), parseInt(matches[8]), parseInt(matches[9]), parseInt(matches[10]), ], bonus: parseInt(matches[11]), prize: parseInt(matches[12].replaceAll(',', '')) }; results.push(data); }); JSON.stringify(results);
We put everything in a file called historical_data.json
in our assets folder. This looks like this:
{ "data": [ { "date":"2020-11-07", "month":11, "year":2020, "numbers":[ 3, 33, 45, 50, 52, 56 ], "bonus":44, "prize":20000000 }, { "date":"2020-11-04", "month":11, "year":2020, "numbers":[ 10, 20, 28, 32, 35, 50 ], "bonus":24, "prize":12980180 }, ....
Now we need our service to access that file instead of returning static data:
class LottoHistoryService { List<LottoResult> _cachedList; Future<List<LottoResult>> getHistoricalResults() async { if (_cachedList == null) { List<dynamic> json = await _getJsonFromFile(); _cachedList = _jsonToSearchTypes(json); } return _cachedList; } Future<List<dynamic>> _getJsonFromFile() async { String jsonString = await rootBundle.loadString('assets/historical_data.json'); return jsonDecode(jsonString)['data']; } List<LottoResult> _jsonToSearchTypes(List<dynamic> json) { List<LottoResult> lottoResults = []; for (var element in json) { lottoResults.add( LottoResult.fromJson(element) ); } return lottoResults; } } class LottoResult { Prizes prizes; DateTime dateTime; CorrectNumbers correctNumbers; LottoResult({ @required this.prizes, @required this.dateTime, @required this.correctNumbers }); LottoResult.fromJson(Map<dynamic, dynamic> json) { this.dateTime = DateTime.parse(json['date']); this.prizes = Prizes( match6: Prize(amount: json['prize']), match5plusBonus: Prize(amount: 1000000), match5: Prize(amount: 1000000), match4: Prize(amount: 1750), match3: Prize(amount: 140), match2: Prize(amount: 0), ); List<int> numbers = []; for (var number in json['numbers']) { numbers.add(number); } this.correctNumbers = CorrectNumbers( numbers: numbers ); } } class Prizes { Prize match6; Prize match5plusBonus; Prize match5; Prize match4; Prize match3; Prize match2; Prizes({ @required this.match6, @required this.match5plusBonus, @required this.match5, @required this.match4, @required this.match3, @required this.match2, }); } class Prize { int amount; int winners; Prize({ @required this.amount, this.winners = 0 }); } class CorrectNumbers { @required List<int> numbers; @required int bonus; CorrectNumbers({ this.numbers, this.bonus }); }
It’s important that we gave our model a fromJson()
method. This way, we can just read the JSON file from our assets folder and put it into the fromJson()
method which returns the same class it returned before. Other than that, we don’t do much apart from caching the result.
Final words
We used the archive data of the UK lottery and an architecture with a bloc and a service to provide a lotto retrospective. Check out, how your favorite numbers would have performed in the UK lottery in 2020 :).
If you like what you’ve read, feel free to support me:
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK