feat: zaps / profiles

This commit is contained in:
2025-05-09 12:23:39 +01:00
parent ab5bdcca69
commit 0ba0839863
25 changed files with 778 additions and 79 deletions

View File

@ -4,4 +4,5 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
</manifest>

View File

@ -31,6 +31,10 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:developer' as developer;
import 'package:flutter/widgets.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -19,14 +20,18 @@ class Account {
Nip19.isKey("nsec", key) ? Bip340.getPublicKey(keyData) : keyData;
final privateKey = Nip19.isKey("npub", key) ? null : keyData;
return Account._(
type: AccountType.privateKey, pubkey: pubkey, privateKey: privateKey);
type: AccountType.privateKey,
pubkey: pubkey,
privateKey: privateKey,
);
}
static Account privateKeyHex(String key) {
return Account._(
type: AccountType.privateKey,
privateKey: key,
pubkey: Bip340.getPublicKey(key));
type: AccountType.privateKey,
privateKey: key,
pubkey: Bip340.getPublicKey(key),
);
}
static Account externalPublicKeyHex(String key) {
@ -34,18 +39,20 @@ class Account {
}
static Map<String, dynamic> toJson(Account? acc) => {
"type": acc?.type.name,
"pubKey": acc?.pubkey,
"privateKey": acc?.privateKey
};
"type": acc?.type.name,
"pubKey": acc?.pubkey,
"privateKey": acc?.privateKey,
};
static Account? fromJson(Map<String, dynamic> json) {
if (json.length > 2 && json.containsKey("pubKey")) {
return Account._(
type: AccountType.values
.firstWhere((v) => v.toString().endsWith(json["type"] as String)),
pubkey: json["pubKey"],
privateKey: json["privateKey"]);
type: AccountType.values.firstWhere(
(v) => v.toString().endsWith(json["type"] as String),
),
pubkey: json["pubKey"],
privateKey: json["privateKey"],
);
}
return null;
}
@ -62,10 +69,19 @@ class LoginData extends ValueNotifier<Account?> {
});
}
Future<void> logout() async {
super.value = null;
await _storage.delete(key: _storageKey);
}
Future<void> load() async {
final acc = await _storage.read(key: _storageKey);
if (acc != null) {
super.value = Account.fromJson(json.decode(acc));
if (acc?.isNotEmpty ?? false) {
try {
super.value = Account.fromJson(json.decode(acc!));
} catch (e) {
developer.log(e.toString());
}
}
}
}

View File

@ -7,6 +7,7 @@ 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/login.dart';
import 'package:zap_stream_flutter/pages/profile.dart';
import 'package:zap_stream_flutter/pages/stream.dart';
import 'package:zap_stream_flutter/theme.dart';
import 'package:zap_stream_flutter/utils.dart';
@ -42,26 +43,29 @@ Future<void> main() async {
// reload / cache login data
loginData.addListener(() {
if (loginData.value != null) {
if (!ndk.accounts.hasAccount(loginData.value!.pubkey)) {
final pubkey = loginData.value!.pubkey;
if (!ndk.accounts.hasAccount(pubkey)) {
switch (loginData.value!.type) {
case AccountType.privateKey:
ndk.accounts.loginPrivateKey(
pubkey: loginData.value!.pubkey,
pubkey: pubkey,
privkey: loginData.value!.privateKey!,
);
case AccountType.externalSigner:
ndk.accounts.loginExternalSigner(
signer: AmberEventSigner(
publicKey: loginData.value!.pubkey,
publicKey: pubkey,
amberFlutterDS: AmberFlutterDS(Amberflutter()),
),
);
case AccountType.publicKey:
ndk.accounts.loginPublicKey(pubkey: loginData.value!.pubkey);
ndk.accounts.loginPublicKey(pubkey: pubkey);
}
}
ndk.metadata.loadMetadata(loginData.value!.pubkey);
ndk.follows.getContactList(loginData.value!.pubkey);
ndk.metadata.loadMetadata(pubkey);
ndk.follows.getContactList(pubkey);
} else {
ndk.accounts.logout();
}
});
@ -94,6 +98,12 @@ Future<void> main() async {
}
},
),
GoRoute(
path: "/p/:id",
builder: (ctx, state) {
return ProfilePage(pubkey: state.pathParameters["id"]!);
},
),
],
),
],

View File

@ -16,7 +16,9 @@ class HomePage extends StatelessWidget {
children: [
HeaderWidget(),
RxFilter<Nip01Event>(
filter: Filter(kinds: [30_311], limit: 10),
filters: [
Filter(kinds: [30_311], limit: 50),
],
builder: (ctx, state) {
if (state == null) {
return SizedBox.shrink();

View File

@ -4,10 +4,11 @@ import 'package:go_router/go_router.dart';
import 'package:ndk/shared/nips/nip19/nip19.dart';
import 'package:zap_stream_flutter/login.dart';
import 'package:zap_stream_flutter/main.dart';
import 'package:zap_stream_flutter/theme.dart';
import 'package:zap_stream_flutter/widgets/button.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<StatefulWidget> createState() => _LoginPage();
}
@ -42,7 +43,7 @@ class _LoginPage extends State<LoginPage> {
}
},
),
BasicButton.text("Login with Key"),
/*BasicButton.text("Login with Key"),
Container(
margin: EdgeInsets.symmetric(vertical: 20),
height: 1,
@ -50,7 +51,7 @@ class _LoginPage extends State<LoginPage> {
border: Border(bottom: BorderSide(color: LAYER_1)),
),
),
Text("Create Account"),
Text("Create Account"),*/
],
),
);

104
lib/pages/profile.dart Normal file
View File

@ -0,0 +1,104 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:ndk/ndk.dart';
import 'package:ndk/shared/nips/nip19/nip19.dart';
import 'package:zap_stream_flutter/imgproxy.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/widgets/avatar.dart';
import 'package:zap_stream_flutter/widgets/button.dart';
import 'package:zap_stream_flutter/widgets/header.dart';
import 'package:zap_stream_flutter/widgets/profile.dart';
import 'package:zap_stream_flutter/widgets/stream_grid.dart';
class ProfilePage extends StatelessWidget {
final String pubkey;
const ProfilePage({super.key, required this.pubkey});
@override
Widget build(BuildContext context) {
final hexPubkey = Nip19.decode(pubkey);
return ProfileLoaderWidget(hexPubkey, (ctx, state) {
final profile = state.data ?? Metadata(pubKey: hexPubkey);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
HeaderWidget(),
if (profile.banner != null)
SizedBox(
height: 140,
width: double.maxFinite,
child: CachedNetworkImage(
imageUrl: proxyImg(context, profile.banner!),
fit: BoxFit.cover,
),
),
Row(
spacing: 8,
children: [
AvatarWidget(profile: profile, size: 80),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfileNameWidget(
profile: profile,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
Text(
profile.about ?? "",
style: TextStyle(color: LAYER_5),
),
],
),
),
],
),
if (ndk.accounts.getPublicKey() == hexPubkey)
Row(
children: [
BasicButton.text(
"Logout",
onTap: () {
loginData.logout();
context.go("/");
},
),
],
),
Text(
"Past Streams",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
RxFilter<Nip01Event>(
key: Key("profile-streams:$hexPubkey"),
relays: defaultRelays,
filters: [
Filter(kinds: [30_311], limit: 200, pTags: [hexPubkey]),
Filter(kinds: [30_311], limit: 200, authors: [hexPubkey]),
],
builder: (ctx, state) {
return StreamGrid(
events: state ?? [],
showLive: true,
showEnded: true,
showPlanned: true,
);
},
),
],
),
);
});
}
}

View File

@ -1,12 +1,15 @@
import 'package:flutter/widgets.dart';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:zap_stream_flutter/main.dart';
import 'package:zap_stream_flutter/theme.dart';
import 'package:zap_stream_flutter/utils.dart';
import 'package:zap_stream_flutter/widgets/button.dart';
import 'package:zap_stream_flutter/widgets/chat.dart';
import 'package:zap_stream_flutter/widgets/pill.dart';
import 'package:zap_stream_flutter/widgets/profile.dart';
import 'package:zap_stream_flutter/widgets/zap.dart';
class StreamPage extends StatefulWidget {
final StreamEvent stream;
@ -19,6 +22,7 @@ class StreamPage extends StatefulWidget {
class _StreamPage extends State<StreamPage> {
VideoPlayerController? _controller;
ChewieController? _chewieController;
@override
void initState() {
@ -34,10 +38,15 @@ class _StreamPage extends State<StreamPage> {
_controller = VideoPlayerController.networkUrl(
Uri.parse(url),
httpHeaders: Map.from({"user-agent": userAgent}),
videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true),
);
() async {
await _controller!.initialize();
await _controller!.play();
_chewieController = ChewieController(
videoPlayerController: _controller!,
autoPlay: true,
);
setState(() {
// nothing
});
@ -64,8 +73,8 @@ class _StreamPage extends State<StreamPage> {
AspectRatio(
aspectRatio: 16 / 9,
child:
_controller != null
? VideoPlayer(_controller!)
_chewieController != null
? Chewie(controller: _chewieController!)
: Container(color: LAYER_1),
),
Text(
@ -76,15 +85,45 @@ class _StreamPage extends State<StreamPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ProfileWidget.pubkey(widget.stream.info.host),
PillWidget(
color: LAYER_1,
child: Text(
"${widget.stream.info.participants} viewers",
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
Row(
spacing: 8,
children: [
BasicButton(
Row(children: [Icon(Icons.bolt, size: 14), Text("Zap")]),
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: PRIMARY_1,
borderRadius: DEFAULT_BR,
),
onTap: () {
showModalBottomSheet(
context: context,
constraints: BoxConstraints.expand(),
builder: (ctx) {
return ZapWidget(
pubkey: widget.stream.info.host,
target: widget.stream.event,
);
},
);
},
),
if (widget.stream.info.participants != null)
PillWidget(
color: LAYER_1,
child: Text(
"${widget.stream.info.participants} viewers",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
SizedBox(height: 10),
Expanded(child: ChatWidget(stream: widget.stream)),
],
);

View File

@ -8,7 +8,7 @@ import 'package:zap_stream_flutter/main.dart';
/// Reactive filter which builds the widget with a snapshot of the data
class RxFilter<T> extends StatefulWidget {
final Filter filter;
final List<Filter> filters;
final bool leaveOpen;
final Widget Function(BuildContext, List<T>?) builder;
final T Function(Nip01Event)? mapper;
@ -16,7 +16,7 @@ class RxFilter<T> extends StatefulWidget {
const RxFilter({
super.key,
required this.filter,
required this.filters,
required this.builder,
this.mapper,
this.leaveOpen = true,
@ -34,16 +34,16 @@ class _RxFilter<T> extends State<RxFilter<T>> {
@override
void initState() {
super.initState();
developer.log("RX:SEDNING ${widget.filter}");
developer.log("RX:SEDNING ${widget.filters}");
_response = ndk.requests.subscription(
filters: [widget.filter],
filters: widget.filters,
cacheRead: true,
cacheWrite: true,
explicitRelays: widget.relays,
);
if (!widget.leaveOpen) {
_response.future.then((_) {
developer.log("RX:CLOSING ${widget.filter}");
developer.log("RX:CLOSING ${widget.filters}");
ndk.requests.closeSubscription(_response.requestId);
});
}
@ -56,7 +56,7 @@ class _RxFilter<T> extends State<RxFilter<T>> {
.listen((events) {
setState(() {
_events ??= HashMap();
developer.log("RX:GOT ${events.length} events for ${widget.filter}");
developer.log("RX:GOT ${events.length} events for ${widget.filters}");
events.forEach(_replaceInto);
});
});
@ -85,7 +85,7 @@ class _RxFilter<T> extends State<RxFilter<T>> {
void dispose() {
super.dispose();
developer.log("RX:CLOSING ${widget.filter}");
developer.log("RX:CLOSING ${widget.filters}");
ndk.requests.closeSubscription(_response.requestId);
}
@ -98,7 +98,7 @@ class _RxFilter<T> extends State<RxFilter<T>> {
/// An async filter loader into [RxFilter]
class RxFutureFilter<T> extends StatelessWidget {
final Future<Filter> Function() filterBuilder;
final Future<List<Filter>> Function() filterBuilder;
final bool leaveOpen;
final Widget Function(BuildContext, List<T>?) builder;
final Widget? loadingWidget;
@ -115,12 +115,12 @@ class RxFutureFilter<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder<Filter>(
return FutureBuilder<List<Filter>>(
future: filterBuilder(),
builder: (ctx, data) {
if (data.hasData) {
return RxFilter<T>(
filter: data.data!, mapper: mapper, builder: builder);
filters: data.data!, mapper: mapper, builder: builder);
} else {
return loadingWidget ?? SizedBox.shrink();
}

View File

@ -10,8 +10,8 @@ Color LAYER_1 = Color.fromARGB(255, 23, 23, 23);
Color LAYER_2 = Color.fromARGB(255, 34, 34, 34);
Color LAYER_3 = Color.fromARGB(255, 50, 50, 50);
Color LAYER_4 = Color.fromARGB(255, 121, 121, 121);
Color LAYER_5 = Color.fromARGB(255, 173, 173, 173);
Color PRIMARY_1 = Color.fromARGB(255, 248, 56, 217);
Color SECONDARY_1 = Color.fromARGB(255, 52, 210, 254);
Color NEUTRAL_500 = Color.fromARGB(255, 155, 155, 155);
Color NEUTRAL_800 = Color.fromARGB(255, 32, 32, 32);
Color ZAP_1 = Color.fromARGB(255, 255, 141, 43);

View File

@ -114,7 +114,11 @@ StreamInfo extractStreamInfo(Nip01Event ev) {
matchTag(t, 'recording', (v) => ret.recording = v);
matchTag(t, 'url', (v) => ret.recording = v);
matchTag(t, 'content-warning', (v) => ret.contentWarning = v);
matchTag(t, 'current_participants', (v) => ret.participants = int.tryParse(v));
matchTag(
t,
'current_participants',
(v) => ret.participants = int.tryParse(v),
);
matchTag(t, 'goal', (v) => ret.goal = v);
matchTag(t, 'starts', (v) => ret.starts = int.tryParse(v));
matchTag(t, 'ends', (v) => ret.ends = int.tryParse(v));
@ -135,6 +139,10 @@ StreamInfo extractStreamInfo(Nip01Event ev) {
} else {
ret.stream = ret.streams.firstWhereOrNull((a) => a.contains('.m3u8'));
}
if (ret.status == StreamStatus.ended &&
(ret.recording?.isNotEmpty ?? false)) {
ret.stream = ret.recording;
}
}
return ret;
@ -213,3 +221,20 @@ class Category {
}
List<Category> AllCategories = []; // Implement as needed
String formatSats(int n) {
if (n >= 1000000) {
return "${(n / 1000000).toStringAsFixed(1)}M";
} else if (n >= 1000) {
return "${(n / 1000).toStringAsFixed(1)}k";
} else {
return "$n";
}
}
String zapSum(List<Nip01Event> zaps) {
final total = zaps
.map((e) => ZapReceipt.fromEvent(e))
.fold(0, (acc, v) => acc + (v.amountSats ?? 0));
return formatSats(total);
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:zap_stream_flutter/theme.dart';
class BasicButton extends StatelessWidget {
@ -35,7 +35,7 @@ class BasicButton extends StatelessWidget {
),
),
decoration: decoration,
padding: padding ?? EdgeInsets.symmetric(vertical: 10),
padding: padding ?? EdgeInsets.symmetric(vertical: 4, horizontal: 12),
margin: margin,
onTap: onTap,
);
@ -43,6 +43,7 @@ class BasicButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final defaultBr = BorderRadius.all(Radius.circular(100));
return GestureDetector(
onTap: onTap,
child: Container(
@ -50,10 +51,7 @@ class BasicButton extends StatelessWidget {
margin: margin,
decoration:
decoration ??
BoxDecoration(
color: LAYER_2,
borderRadius: BorderRadius.all(Radius.circular(100)),
),
BoxDecoration(color: LAYER_2, borderRadius: defaultBr),
child: Center(child: child),
),
);

View File

@ -24,7 +24,9 @@ class ChatWidget extends StatelessWidget {
child: SingleChildScrollView(
reverse: true,
child: RxFilter<Nip01Event>(
filter: Filter(kinds: [1311], limit: 200, aTags: [stream.aTag]),
filters: [
Filter(kinds: [1311, 9735], limit: 200, aTags: [stream.aTag]),
],
builder: (ctx, state) {
return Column(
spacing: 8,
@ -32,14 +34,105 @@ class ChatWidget extends StatelessWidget {
children:
(state ?? [])
.sortedBy((c) => c.createdAt)
.map((c) => ChatMessageWidget(stream: stream, msg: c))
.map(
(c) => switch (c.kind) {
1311 => ChatMessageWidget(stream: stream, msg: c),
9735 => ChatZapWidget(stream: stream, zap: c),
_ => SizedBox.shrink(),
},
)
.toList(),
);
},
),
),
),
WriteMessageWidget(stream: stream),
if (stream.info.status == StreamStatus.live)
WriteMessageWidget(stream: stream),
if (stream.info.status == StreamStatus.ended)
Container(
padding: EdgeInsets.all(8),
margin: EdgeInsets.symmetric(vertical: 8),
width: double.maxFinite,
alignment: Alignment.center,
decoration: BoxDecoration(
color: PRIMARY_1,
borderRadius: DEFAULT_BR,
),
child: Text(
"STREAM ENDED",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
);
}
}
class ChatZapWidget extends StatelessWidget {
final StreamEvent stream;
final Nip01Event zap;
const ChatZapWidget({super.key, required this.stream, required this.zap});
@override
Widget build(BuildContext context) {
final parsed = ZapReceipt.fromEvent(zap);
return Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: ZAP_1),
borderRadius: DEFAULT_BR,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_zapperRowZap(parsed),
if (parsed.comment?.isNotEmpty ?? false) Text(parsed.comment!),
],
),
);
}
Widget _zapperRowZap(ZapReceipt parsed) {
if (parsed.sender != null) {
return ProfileLoaderWidget(parsed.sender!, (ctx, state) {
final name = ProfileNameWidget.nameFromProfile(
state.data ?? Metadata(pubKey: parsed.sender!),
);
return _zapperRow(name, parsed.amountSats ?? 0, state.data);
});
} else {
return _zapperRow("Anon", parsed.amountSats ?? 0, null);
}
}
Widget _zapperRow(String name, int amount, Metadata? profile) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
RichText(
text: TextSpan(
style: TextStyle(color: ZAP_1),
children: [
WidgetSpan(
child: Icon(Icons.bolt, color: ZAP_1),
alignment: PlaceholderAlignment.middle,
),
if (profile != null)
WidgetSpan(
child: Padding(
padding: EdgeInsets.only(right: 8),
child: AvatarWidget(profile: profile, size: 20),
),
),
TextSpan(text: name),
TextSpan(text: " zapped ", style: TextStyle(color: FONT_COLOR)),
TextSpan(text: formatSats(amount)),
TextSpan(text: " sats", style: TextStyle(color: FONT_COLOR)),
],
),
),
],
);
}
@ -58,12 +151,19 @@ class ChatMessageWidget extends StatelessWidget {
return RichText(
text: TextSpan(
children: [
WidgetSpan(child: AvatarWidget(profile: profile, size: 20)),
WidgetSpan(
child: AvatarWidget(profile: profile, size: 24),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: " "),
TextSpan(
text: ProfileNameWidget.nameFromProfile(profile),
style: TextStyle(
color: msg.pubKey == stream.info.host ? PRIMARY_1 : SECONDARY_1,
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: ProfileNameWidget(
profile: profile,
style: TextStyle(
color:
msg.pubKey == stream.info.host ? PRIMARY_1 : SECONDARY_1,
),
),
),
TextSpan(text: " "),
@ -120,6 +220,7 @@ class _WriteMessageWidget extends State<WriteMessageWidget> {
Expanded(
child: TextField(
controller: _controller,
onSubmitted: (_) => _sendMessage(),
decoration: InputDecoration(
labelText: "Write message",
contentPadding: EdgeInsets.symmetric(vertical: 4),
@ -128,7 +229,7 @@ class _WriteMessageWidget extends State<WriteMessageWidget> {
),
),
),
IconButton(onPressed: () {}, icon: Icon(Icons.mood)),
//IconButton(onPressed: () {}, icon: Icon(Icons.mood)),
IconButton(
onPressed: () {
_sendMessage();

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
import 'package:ndk/shared/nips/nip19/nip19.dart';
import 'package:zap_stream_flutter/main.dart';
import 'package:zap_stream_flutter/theme.dart';
import 'package:zap_stream_flutter/widgets/avatar.dart';
@ -20,7 +21,10 @@ class _HeaderWidget extends State<HeaderWidget> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SvgPicture.asset("assets/svg/logo.svg", height: 23),
GestureDetector(
onTap: () => context.go("/"),
child: SvgPicture.asset("assets/svg/logo.svg", height: 23),
),
LoginButtonWidget(),
],
),
@ -29,10 +33,18 @@ class _HeaderWidget extends State<HeaderWidget> {
}
class LoginButtonWidget extends StatelessWidget {
const LoginButtonWidget({super.key});
@override
Widget build(BuildContext context) {
if (ndk.accounts.isLoggedIn) {
return AvatarWidget.pubkey(ndk.accounts.getPublicKey()!);
return GestureDetector(
onTap:
() => context.go(
"/p/${Nip19.encodePubKey(ndk.accounts.getPublicKey()!)}",
),
child: AvatarWidget.pubkey(ndk.accounts.getPublicKey()!),
);
} else {
return GestureDetector(
onTap: () {

View File

@ -1,4 +1,5 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:ndk/ndk.dart';
import 'package:ndk/shared/nips/nip19/nip19.dart';
import 'package:zap_stream_flutter/main.dart';
@ -48,7 +49,14 @@ class ProfileNameWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text(ProfileNameWidget.nameFromProfile(profile), style: style);
return GestureDetector(
onTap:
() => context.push(
"/p/${Nip19.encodePubKey(profile.pubKey)}",
extra: profile,
),
child: Text(ProfileNameWidget.nameFromProfile(profile), style: style),
);
}
}
@ -64,9 +72,12 @@ class ProfileWidget extends StatelessWidget {
this.size,
});
static Widget pubkey(String pubkey) {
static Widget pubkey(String pubkey, {double? size}) {
return ProfileLoaderWidget(pubkey, (ctx, state) {
return ProfileWidget(profile: state.data ?? Metadata(pubKey: pubkey));
return ProfileWidget(
profile: state.data ?? Metadata(pubKey: pubkey),
size: size,
);
});
}

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:ndk/ndk.dart';
import 'package:zap_stream_flutter/theme.dart';
@ -6,13 +7,35 @@ import 'package:zap_stream_flutter/widgets/stream_tile.dart';
class StreamGrid extends StatelessWidget {
final List<Nip01Event> events;
const StreamGrid({super.key, required this.events});
final bool showEnded;
final bool showLive;
final bool showPlanned;
const StreamGrid({
super.key,
required this.events,
this.showLive = true,
this.showEnded = false,
this.showPlanned = false,
});
@override
Widget build(BuildContext context) {
final streams = events.map((e) => StreamEvent(e));
final streams = events
.map((e) => StreamEvent(e))
.where((e) => e.info.stream?.isNotEmpty ?? false)
.sortedBy((a) => a.info.starts ?? a.event.createdAt);
final live = streams.where((s) => s.info.status == StreamStatus.live);
return Column(children: [_streamGroup("Live", live)]);
final ended = streams.where((s) => s.info.status == StreamStatus.ended);
final planned = streams.where((s) => s.info.status == StreamStatus.planned);
return Column(
spacing: 16,
children: [
if (showLive && live.isNotEmpty) _streamGroup("Live", live),
if (showPlanned && planned.isNotEmpty) _streamGroup("Planned", planned),
if (showEnded && ended.isNotEmpty) _streamGroup("Ended", ended),
],
);
}
Widget _streamGroup(String title, Iterable<StreamEvent> events) {
@ -30,7 +53,7 @@ class StreamGrid extends StatelessWidget {
child: Container(
height: 1,
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: LAYER_1)),
border: Border(bottom: BorderSide(color: LAYER_2)),
),
),
),

View File

@ -1,5 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:ndk/shared/nips/nip19/nip19.dart';
import 'package:zap_stream_flutter/imgproxy.dart';
@ -34,15 +35,31 @@ class StreamTileWidget extends StatelessWidget {
aspectRatio: 16 / 9,
child: Stack(
children: [
CachedNetworkImage(
imageUrl: proxyImg(context, stream.info.image ?? ""),
Center(
child: CachedNetworkImage(
imageUrl: proxyImg(context, stream.info.image ?? ""),
fit: BoxFit.cover,
placeholder:
(ctx, url) => SvgPicture.asset(
"assets/svg/logo.svg",
height: 100,
),
errorWidget:
(context, url, error) => SvgPicture.asset(
"assets/svg/logo.svg",
height: 100,
),
),
),
if (stream.info.status != null)
Positioned(
right: 8,
top: 8,
child: PillWidget(
color: Theme.of(context).highlightColor,
color: switch (stream.info.status) {
StreamStatus.live => Theme.of(context).highlightColor,
_ => LAYER_3,
},
child: Text(
stream.info.status!.name.toUpperCase(),
style: TextStyle(

192
lib/widgets/zap.dart Normal file
View File

@ -0,0 +1,192 @@
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart';
import 'package:ndk/ndk.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:zap_stream_flutter/main.dart';
import 'package:zap_stream_flutter/theme.dart';
import 'package:zap_stream_flutter/utils.dart';
import 'package:zap_stream_flutter/widgets/button.dart';
import 'package:zap_stream_flutter/widgets/profile.dart';
class ZapWidget extends StatefulWidget {
final String pubkey;
final Nip01Event? target;
const ZapWidget({super.key, required this.pubkey, this.target});
@override
State<StatefulWidget> createState() => _ZapWidget();
}
class _ZapWidget extends State<ZapWidget> {
final TextEditingController _comment = TextEditingController();
String? _error;
String? _pr;
int? _amount;
final _zapAmounts = [
50,
100,
200,
500,
1000,
5000,
10000,
50000,
100000,
1000000,
];
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(10),
child: Column(
spacing: 10,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 5,
children: [
Text(
"Zap",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
ProfileNameWidget.pubkey(
widget.pubkey,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
if (_pr == null) ..._inputs(),
if (_pr != null) ..._invoice(),
],
),
);
}
List<Widget> _inputs() {
return [
GridView.builder(
shrinkWrap: true,
itemCount: _zapAmounts.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 5,
crossAxisSpacing: 5,
childAspectRatio: 1.5,
),
itemBuilder: (ctx, idx) => _zapAmount(_zapAmounts[idx]),
),
TextFormField(
controller: _comment,
decoration: InputDecoration(labelText: "Comment"),
),
BasicButton.text(
"Zap",
onTap: () {
try {
_loadZap();
} catch (e) {
setState(() {
_error = e.toString();
});
}
},
),
if (_error != null) Text(_error!),
];
}
List<Widget> _invoice() {
return [
QrImageView(
data: _pr!,
size: 256,
backgroundColor: Colors.transparent,
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: FONT_COLOR,
),
eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.square, color: FONT_COLOR),
),
GestureDetector(
onTap: () async {
await FlutterClipboard.copy(_pr!);
},
child: Text(_pr!, overflow: TextOverflow.ellipsis),
),
BasicButton.text(
"Open in Wallet",
onTap: () async {
try {
await launchUrlString("lightning:${_pr!}");
} catch (e) {
setState(() {
_error = e is String ? e : e.toString();
});
}
},
),
if (_error != null) Text(_error!),
];
}
Future<void> _loadZap() async {
final profile = await ndk.metadata.loadMetadata(widget.pubkey);
if (profile?.lud16 == null) {
throw "No lightning address found";
}
final signer = ndk.accounts.getLoggedAccount()?.signer;
final zapRequest =
signer != null
? await ndk.zaps.createZapRequest(
amountSats: _amount!,
signer: signer,
pubKey: widget.pubkey,
eventId: widget.target?.id,
addressableId:
widget.target != null
? "${widget.target!.kind}:${widget.target!.pubKey}:${widget.target!.getDtag()!}"
: null,
relays: defaultRelays,
comment: _comment.text.isNotEmpty ? _comment.text : null,
)
: null;
final invoice = await ndk.zaps.fetchInvoice(
lud16Link: Lnurl.getLud16LinkFromLud16(profile!.lud16!)!,
amountSats: _amount!,
zapRequest: zapRequest,
);
setState(() {
_pr = invoice?.invoice;
});
}
Widget _zapAmount(int n) {
return GestureDetector(
onTap:
() => setState(() {
_amount = n;
}),
child: Container(
decoration: BoxDecoration(
color: n == _amount ? LAYER_2 : LAYER_1,
borderRadius: DEFAULT_BR,
),
alignment: AlignmentDirectional.center,
child: Text(
formatSats(n),
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
);
}
}

View File

@ -8,6 +8,7 @@
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) objectbox_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ObjectboxFlutterLibsPlugin");
objectbox_flutter_libs_plugin_register_with_registrar(objectbox_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
objectbox_flutter_libs
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@ -10,6 +10,7 @@ import objectbox_flutter_libs
import package_info_plus
import path_provider_foundation
import sqflite_darwin
import url_launcher_macos
import video_player_avfoundation
import wakelock_plus
@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
}

View File

@ -89,6 +89,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
chewie:
dependency: "direct main"
description:
name: chewie
sha256: "4d9554a8f87cc2dc6575dfd5ad20a4375015a29edd567fd6733febe6365e2566"
url: "https://pub.dev"
source: hosted
version: "1.11.3"
clipboard:
dependency: "direct main"
description:
name: clipboard
sha256: "2ec38f0e59878008ceca0ab122e4bfde98847f88ef0f83331362ba4521f565a9"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
clock:
dependency: transitive
description:
@ -137,6 +153,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: transitive
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dbus:
dependency: transitive
description:
@ -419,10 +443,11 @@ packages:
ndk:
dependency: "direct main"
description:
name: ndk
sha256: "386a2e388785960a7e0c1cecf13e4440bcee30d44d07a03b37b8abd76aefafb0"
url: "https://pub.dev"
source: hosted
path: "packages/ndk"
ref: bbf2aa9c2468b2301de65734199649d56bb0fd74
resolved-ref: bbf2aa9c2468b2301de65734199649d56bb0fd74
url: "https://github.com/relaystr/ndk"
source: git
version: "0.3.2"
ndk_amber:
dependency: "direct main"
@ -448,6 +473,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objectbox:
dependency: transitive
description:
@ -584,6 +617,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.9.1"
provider:
dependency: transitive
description:
name: provider
sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
rust_lib_ndk:
dependency: transitive
description:
@ -717,6 +774,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
url: "https://pub.dev"
source: hosted
version: "6.3.1"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
url: "https://pub.dev"
source: hosted
version: "6.3.16"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
url: "https://pub.dev"
source: hosted
version: "6.3.3"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
description:

View File

@ -24,6 +24,17 @@ dependencies:
convert: ^3.1.2
collection: ^1.19.1
video_player: ^2.9.5
clipboard: ^0.1.3
qr_flutter: ^4.1.0
url_launcher: ^6.3.1
chewie: ^1.11.3
dependency_overrides:
ndk:
git:
url: https://github.com/relaystr/ndk
path: packages/ndk
ref: bbf2aa9c2468b2301de65734199649d56bb0fd74
dev_dependencies:
flutter_test:

View File

@ -8,10 +8,13 @@
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
ObjectboxFlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
objectbox_flutter_libs
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST