diff --git a/assets/category/art.jpeg b/assets/category/art.jpeg new file mode 100644 index 0000000..83c5979 Binary files /dev/null and b/assets/category/art.jpeg differ diff --git a/assets/category/gaming.jpeg b/assets/category/gaming.jpeg new file mode 100644 index 0000000..7e9058c Binary files /dev/null and b/assets/category/gaming.jpeg differ diff --git a/assets/category/irl.jpeg b/assets/category/irl.jpeg new file mode 100644 index 0000000..c75f09c Binary files /dev/null and b/assets/category/irl.jpeg differ diff --git a/assets/category/music.jpeg b/assets/category/music.jpeg new file mode 100644 index 0000000..fd5bc31 Binary files /dev/null and b/assets/category/music.jpeg differ diff --git a/assets/category/talk.jpeg b/assets/category/talk.jpeg new file mode 100644 index 0000000..7ec845c Binary files /dev/null and b/assets/category/talk.jpeg differ diff --git a/lib/main.dart b/lib/main.dart index 0ecb538..6304b68 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,8 @@ import 'package:ndk/ndk.dart'; import 'package:ndk_amber/ndk_amber.dart'; import 'package:ndk_objectbox/ndk_objectbox.dart'; import 'package:ndk_rust_verifier/ndk_rust_verifier.dart'; +import 'package:zap_stream_flutter/pages/category.dart'; +import 'package:zap_stream_flutter/pages/hashtag.dart'; import 'package:zap_stream_flutter/pages/login.dart'; import 'package:zap_stream_flutter/pages/login_input.dart'; import 'package:zap_stream_flutter/pages/new_account.dart'; @@ -42,6 +44,7 @@ const defaultRelays = [ "wss://relay.damus.io", "wss://relay.primal.net", "wss://relay.snort.social", + "wss://relay.fountain.fm", ]; const searchRelays = ["wss://relay.nostr.band", "wss://search.nos.today"]; @@ -150,6 +153,21 @@ Future main() async { return ProfilePage(pubkey: state.pathParameters["id"]!); }, ), + GoRoute( + path: "/t/:id", + builder: (context, state) { + return HashtagPage(tag: state.pathParameters["id"]!); + }, + ), + GoRoute( + path: "/category/:id", + builder: (context, state) { + return CategoryPage( + category: state.pathParameters["id"]!, + info: state.extra as GameInfo?, + ); + }, + ), ], ), ], diff --git a/lib/pages/category.dart b/lib/pages/category.dart new file mode 100644 index 0000000..9f7906e --- /dev/null +++ b/lib/pages/category.dart @@ -0,0 +1,110 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/imgproxy.dart'; +import 'package:zap_stream_flutter/rx_filter.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/utils.dart'; +import 'package:zap_stream_flutter/widgets/category_top_zapped.dart'; +import 'package:zap_stream_flutter/widgets/header.dart'; +import 'package:zap_stream_flutter/widgets/pill.dart'; +import 'package:zap_stream_flutter/widgets/stream_grid.dart'; + +class CategoryPage extends StatelessWidget { + final String category; + final GameInfo? info; + + const CategoryPage({super.key, required this.category, required this.info}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Container( + margin: EdgeInsets.all(5.0), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HeaderWidget(), + Row( + spacing: 8, + children: [ + if (info?.coverImage != null) + Container( + clipBehavior: Clip.antiAlias, + constraints: BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + borderRadius: DEFAULT_BR, + color: LAYER_1, + ), + child: + info!.coverImage!.startsWith("assets/") + ? Image.asset( + info!.coverImage!, + fit: BoxFit.contain, + ) + : CachedNetworkImage( + imageUrl: proxyImg(context, info!.coverImage!), + fit: BoxFit.cover, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + info?.name ?? category, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if (info?.genres != null) + Row( + spacing: 4, + children: + info!.genres + .map( + (g) => PillWidget( + color: LAYER_1, + child: Text( + g, + style: TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ) + .toList(), + ), + CategoryTopZapped(tag: category), + ], + ), + ), + ], + ), + RxFilter( + Key("category-page:$category"), + filters: [ + Filter( + kinds: [30_311], + limit: 100, + tTags: [category.toLowerCase()], + ), + ], + builder: (ctx, state) { + if (state == null) { + return SizedBox.shrink(); + } else { + return StreamGrid(events: state); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/hashtag.dart b/lib/pages/hashtag.dart new file mode 100644 index 0000000..496a514 --- /dev/null +++ b/lib/pages/hashtag.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/rx_filter.dart'; +import 'package:zap_stream_flutter/widgets/category_top_zapped.dart'; +import 'package:zap_stream_flutter/widgets/header.dart'; +import 'package:zap_stream_flutter/widgets/stream_grid.dart'; + +class HashtagPage extends StatelessWidget { + final String tag; + + const HashtagPage({super.key, required this.tag}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Container( + margin: EdgeInsets.all(5.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HeaderWidget(), + Text( + "#$tag", + style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), + ), + CategoryTopZapped(tag: tag), + RxFilter( + Key("tags-page:$tag"), + filters: [ + Filter(kinds: [30_311], limit: 50, tTags: [tag.toLowerCase()]), + ], + builder: (ctx, state) { + if (state == null) { + return SizedBox.shrink(); + } else { + return StreamGrid(events: state); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 510c5dd..81a934e 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -13,6 +13,7 @@ class HomePage extends StatelessWidget { child: Container( margin: EdgeInsets.all(5.0), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ HeaderWidget(), RxFilter( @@ -28,7 +29,6 @@ class HomePage extends StatelessWidget { } }, ), - //other stuff.. ], ), ), diff --git a/lib/utils.dart b/lib/utils.dart index 1758384..ea9f952 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:bech32/bech32.dart'; import 'package:collection/collection.dart'; import 'package:convert/convert.dart'; +import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip19/nip19.dart'; @@ -88,16 +89,16 @@ class StreamInfo { } class GameInfo { - String id; - String name; - List genres; - String className; + final String id; + final String name; + final List genres; + final String? coverImage; - GameInfo({ + const GameInfo({ required this.id, required this.name, required this.genres, - required this.className, + required this.coverImage, }); } @@ -196,32 +197,32 @@ StreamInfo extractStreamInfo(Nip01Event ev) { ({GameInfo? gameInfo, String? gameId}) extractGameTag(List tags) { final gameId = tags.firstWhereOrNull((a) => gameTagFormat.hasMatch(a)); - final internalGame = AllCategories.firstWhereOrNull( + final internalGame = allCategories.firstWhereOrNull( (a) => gameId == 'internal:${a.id}', ); if (internalGame != null) { return ( gameInfo: GameInfo( - id: internalGame.id, + id: "internal:${internalGame.id}", name: internalGame.name, genres: internalGame.tags, - className: internalGame.className, + coverImage: internalGame.coverImage, ), gameId: gameId, ); } final lowerTags = tags.map((t) => t.toLowerCase()); - final taggedCategory = AllCategories.firstWhereOrNull( + final taggedCategory = allCategories.firstWhereOrNull( (a) => a.tags.any(lowerTags.contains), ); if (taggedCategory != null) { return ( gameInfo: GameInfo( - id: taggedCategory.id, + id: "internal:${taggedCategory.id}", name: taggedCategory.name, genres: taggedCategory.tags, - className: taggedCategory.className, + coverImage: taggedCategory.coverImage, ), gameId: gameId, ); @@ -237,23 +238,83 @@ String getHost(Nip01Event ev) { } class Category { - String id; - String name; - List tags; - String className; + final String id; + final String name; + final IconData icon; + final List tags; + final int order; + final String? coverImage; - Category({ + const Category({ required this.id, required this.name, + required this.icon, required this.tags, - required this.className, + required this.order, + this.coverImage, }); } -List AllCategories = []; // Implement as needed +const List allCategories = [ + Category( + id: "irl", + name: "IRL", + icon: Icons.face, + tags: ["irl"], + order: 0, + coverImage: "assets/category/irl.jpeg", + ), + Category( + id: "gaming", + name: "Gaming", + icon: Icons.gamepad, + tags: ["gaming"], + order: 0, + coverImage: "assets/category/gaming.jpeg", + ), + Category( + id: "music", + name: "Music", + icon: Icons.note, + tags: ["music", "raido"], + order: 0, + coverImage: "assets/category/music.jpeg", + ), + Category( + id: "talk", + name: "Talk", + icon: Icons.mic, + tags: ["talk", "just-chatting"], + order: 0, + coverImage: "assets/category/talk.jpeg", + ), + Category( + id: "art", + name: "Art", + icon: Icons.brush, + tags: ["art"], + order: 0, + coverImage: "assets/category/art.jpeg", + ), + Category( + id: "gambling", + name: "Gambling", + icon: Icons.casino, + tags: ["gambling", "casino", "slots"], + order: 1, + ), + Category( + id: "science-and-technology", + name: "Science & Technology", + icon: Icons.casino, + tags: ["science", "tech", "technology"], + order: 1, + ), +]; -String formatSats(int n) { +String formatSats(int n, {int? maxDigits}) { final fmt = NumberFormat(); + fmt.maximumFractionDigits = maxDigits ?? 2; if (n >= 1000000) { return "${fmt.format(n / 1000000)}M"; } else if (n >= 1500) { @@ -270,6 +331,49 @@ String zapSum(List zaps) { return formatSats(total); } +class TopZaps { + final int sum; + final List zaps; + + const TopZaps({required this.sum, required this.zaps}); +} + +Map topZapSender(Iterable zaps) { + return Map.fromEntries( + zaps + .where((e) => e.sender != null) + .groupListsBy((v) => v.sender!) + .entries + .map( + (v) => MapEntry( + v.key, + TopZaps( + sum: v.value.fold(0, (acc, v) => acc + (v.amountSats ?? 0)), + zaps: v.value, + ), + ), + ), + ); +} + +Map topZapReceiver(Iterable zaps) { + return Map.fromEntries( + zaps + .where((e) => e.recipient != null) + .groupListsBy((v) => v.recipient!) + .entries + .map( + (v) => MapEntry( + v.key, + TopZaps( + sum: v.value.fold(0, (acc, v) => acc + (v.amountSats ?? 0)), + zaps: v.value, + ), + ), + ), + ); +} + String bech32ToHex(String bech32) { final decoder = Bech32Decoder(); final data = decoder.convert(bech32, 10_000); @@ -365,4 +469,4 @@ String encodeBech32TLV(String hrp, List tlvs) { } catch (e) { throw FormatException('Failed to encode Bech32 or TLV: $e'); } -} \ No newline at end of file +} diff --git a/lib/widgets/category_top_zapped.dart b/lib/widgets/category_top_zapped.dart new file mode 100644 index 0000000..9110c3e --- /dev/null +++ b/lib/widgets/category_top_zapped.dart @@ -0,0 +1,109 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/main.dart'; +import 'package:zap_stream_flutter/rx_filter.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/utils.dart'; +import 'package:zap_stream_flutter/widgets/avatar.dart'; +import 'package:zap_stream_flutter/widgets/profile.dart'; + +class CategoryTopZapped extends StatelessWidget { + final String tag; + final int? limit; + + const CategoryTopZapped({super.key, required this.tag, this.limit}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + RichText( + text: TextSpan( + children: [ + WidgetSpan( + child: Icon(Icons.bolt, color: ZAP_1), + alignment: PlaceholderAlignment.middle, + ), + TextSpan( + text: " Most Zapped Streamers", + style: TextStyle(color: LAYER_4, fontWeight: FontWeight.w500), + ), + ], + ), + ), + + SingleChildScrollView( + scrollDirection: Axis.horizontal, + primary: false, + child: FutureBuilder( + future: + ndk.requests + .query( + filters: [ + Filter( + kinds: [30_311], + limit: 100, + tTags: [tag.toLowerCase()], + ), + ], + ) + .future, + builder: (context, state) { + final aTags = + (state.data ?? []) + .map((e) => "30311:${e.pubKey}:${e.getDtag()}") + .toList(); + return RxFilter( + Key("top-zapped:$tag:${aTags.length}"), + filters: [ + Filter(kinds: [9735], aTags: aTags), + ], + builder: (context, zaps) { + final parsedZaps = + zaps?.map((e) => ZapReceipt.fromEvent(e)) ?? []; + final topZapped = topZapReceiver(parsedZaps).entries + .sortedBy((e) => e.value.sum) + .reversed + .take(limit ?? 5); + + return Row( + spacing: 16, + children: + topZapped + .map( + (e) => Row( + spacing: 8, + children: [ + AvatarWidget.pubkey(e.key), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + formatSats(e.value.sum, maxDigits: 0), + style: TextStyle( + color: ZAP_1, + fontWeight: FontWeight.bold, + ), + ), + ProfileNameWidget.pubkey(e.key), + ], + ), + ], + ), + ) + .toList(), + ); + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/game_info.dart b/lib/widgets/game_info.dart new file mode 100644 index 0000000..1c1a526 --- /dev/null +++ b/lib/widgets/game_info.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/utils.dart'; +import 'package:zap_stream_flutter/widgets/pill.dart'; + +class GameInfoWidget extends StatelessWidget { + final GameInfo info; + + const GameInfoWidget({super.key, required this.info}); + + @override + Widget build(BuildContext context) { + return PillWidget( + color: LAYER_2, + onTap: () { + context.push("/category/${Uri.encodeComponent(info.id)}", extra: info); + }, + child: Text( + info.name, + style: TextStyle(color: PRIMARY_1, fontWeight: FontWeight.bold), + ), + ); + } +} diff --git a/lib/widgets/pill.dart b/lib/widgets/pill.dart index a83e280..7c45091 100644 --- a/lib/widgets/pill.dart +++ b/lib/widgets/pill.dart @@ -3,12 +3,18 @@ import 'package:flutter/material.dart'; class PillWidget extends StatelessWidget { final Widget child; final Color? color; + final void Function()? onTap; - const PillWidget({super.key, required this.child, required this.color}); + const PillWidget({ + super.key, + required this.child, + required this.color, + this.onTap, + }); @override Widget build(BuildContext context) { - return Container( + final inner = Container( padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(8)), @@ -16,5 +22,6 @@ class PillWidget extends StatelessWidget { ), child: child, ); + return onTap != null ? GestureDetector(onTap: onTap, child: inner) : inner; } } diff --git a/lib/widgets/stream_grid.dart b/lib/widgets/stream_grid.dart index 2651c21..bbd9540 100644 --- a/lib/widgets/stream_grid.dart +++ b/lib/widgets/stream_grid.dart @@ -25,6 +25,11 @@ class StreamGrid extends StatelessWidget { events .map((e) => StreamEvent(e)) .where((e) => e.info.stream?.contains(".m3u8") ?? false) + .where( + (e) => + (e.info.starts ?? e.event.createdAt) <= + (DateTime.now().millisecondsSinceEpoch / 1000), + ) .sortedBy((a) => a.info.starts ?? a.event.createdAt) .reversed; final live = streams.where((s) => s.info.status == StreamStatus.live); diff --git a/lib/widgets/stream_info.dart b/lib/widgets/stream_info.dart index 06a919a..99c474d 100644 --- a/lib/widgets/stream_info.dart +++ b/lib/widgets/stream_info.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; import 'package:zap_stream_flutter/widgets/button_follow.dart'; +import 'package:zap_stream_flutter/widgets/game_info.dart'; +import 'package:zap_stream_flutter/widgets/nostr_text.dart'; +import 'package:zap_stream_flutter/widgets/pill.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; import 'package:zap_stream_flutter/widgets/stream_cards.dart'; @@ -45,7 +49,36 @@ class StreamInfoWidget extends StatelessWidget { ), ), if (stream.info.summary?.isNotEmpty ?? false) - Text(stream.info.summary!), + Text.rich( + TextSpan( + children: textToSpans( + stream.info.summary!, + [], + stream.info.host, + ), + ), + ), + + if (stream.info.tags.isNotEmpty || stream.info.gameInfo != null) + Row( + spacing: 2, + children: [ + if (stream.info.gameInfo != null) + GameInfoWidget(info: stream.info.gameInfo!), + ...stream.info.tags.map( + (t) => PillWidget( + color: LAYER_2, + onTap: () { + context.push("/t/${Uri.encodeComponent(t)}"); + }, + child: Text( + t, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), StreamCardsWidget(stream: stream), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index c4caf9f..a0b540d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,4 +60,5 @@ flutter: uses-material-design: true assets: - "assets/svg/" - - "assets/logo.png" \ No newline at end of file + - "assets/logo.png" + - "assets/category/" \ No newline at end of file