feat: profile editor

closes #5
This commit is contained in:
2025-05-16 12:03:59 +01:00
parent d85c93b7ed
commit 7c3e9afc3e
8 changed files with 303 additions and 66 deletions

View File

@ -12,9 +12,11 @@ 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';
import 'package:zap_stream_flutter/pages/profile.dart';
import 'package:zap_stream_flutter/pages/settings_profile.dart';
import 'package:zap_stream_flutter/pages/stream.dart';
import 'package:zap_stream_flutter/theme.dart';
import 'package:zap_stream_flutter/utils.dart';
import 'package:zap_stream_flutter/widgets/header.dart';
import 'login.dart';
import 'pages/home.dart';
@ -168,6 +170,23 @@ Future<void> main() async {
);
},
),
ShellRoute(
builder:
(context, state, child) =>
Column(children: [HeaderWidget(), child]),
routes: [
GoRoute(
path: "/settings",
builder: (context, state) => SizedBox(),
routes: [
GoRoute(
path: "profile",
builder: (context, state) => SettingsProfilePage(),
),
],
),
],
),
],
),
],

View File

@ -27,7 +27,7 @@ class HashtagPage extends StatelessWidget {
RxFilter<Nip01Event>(
Key("tags-page:$tag"),
filters: [
Filter(kinds: [30_311], limit: 50, tTags: [tag.toLowerCase()]),
Filter(kinds: [30_311], limit: 100, tTags: [tag.toLowerCase()]),
],
builder: (ctx, state) {
if (state == null) {

View File

@ -19,7 +19,7 @@ class HomePage extends StatelessWidget {
RxFilter<Nip01Event>(
Key("home-page"),
filters: [
Filter(kinds: [30_311], limit: 50),
Filter(kinds: [30_311], limit: 100),
],
builder: (ctx, state) {
if (state == null) {

View File

@ -1,13 +1,12 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:ndk/ndk.dart';
import 'package:ndk/shared/nips/nip01/bip340.dart';
import 'package:ndk/shared/nips/nip01/key_pair.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/avatar_upload.dart';
import 'package:zap_stream_flutter/widgets/button.dart';
class NewAccountPage extends StatefulWidget {
@ -19,28 +18,12 @@ class NewAccountPage extends StatefulWidget {
class _NewAccountPage extends State<NewAccountPage> {
final TextEditingController _name = TextEditingController();
final FocusNode _nameFocus = FocusNode();
String? _avatar;
String? _error;
bool _loading = false;
final KeyPair _privateKey = Bip340.generatePrivateKey();
Future<void> _uploadAvatar() async {
ndk.accounts.loginPrivateKey(
pubkey: _privateKey.publicKey,
privkey: _privateKey.privateKey!,
);
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
if (file != null) {
final upload = await ndk.blossom.uploadBlob(
serverUrls: ["https://nostr.download"],
data: await file.readAsBytes(),
);
setState(() {
_avatar = upload.first.descriptor!.url;
});
}
}
Future<void> _login() async {
if (ndk.accounts.isNotLoggedIn) {
ndk.accounts.loginPrivateKey(
@ -63,55 +46,58 @@ class _NewAccountPage extends State<NewAccountPage> {
return Column(
spacing: 20,
children: [
GestureDetector(
onTap: () {
_uploadAvatar().catchError((e) {
setState(() {
if (e is String) {
_error = e;
}
});
AvatarUpload(
onUploadStart: () async {
if (ndk.accounts.isNotLoggedIn) {
ndk.accounts.loginPrivateKey(
pubkey: _privateKey.publicKey,
privkey: _privateKey.privateKey!,
);
}
},
onUpload: (i) {
setState(() {
_avatar = i;
});
},
child: Container(
width: 200,
height: 200,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(200)),
color: Color.fromARGB(100, 50, 50, 50),
),
child:
_avatar == null
? Center(child: Text("Upload Avatar"))
: CachedNetworkImage(imageUrl: _avatar!),
),
),
TextField(
controller: _name,
readOnly: _loading,
focusNode: _nameFocus,
decoration: InputDecoration(labelText: "Username"),
),
BasicButton.text(
"Login",
onTap: () {
_login()
.then((_) {
loginData.value = LoginAccount.privateKeyHex(
_privateKey.privateKey!,
);
if (context.mounted) {
context.go("/");
}
})
.catchError((e) {
setState(() {
if (e is String) {
_error = e;
}
});
ValueListenableBuilder(
valueListenable: _name,
builder: (context, value, child) {
return BasicButton.text(
"Login",
disabled: _loading || value.text.isEmpty,
onTap: () {
setState(() {
_loading = true;
_nameFocus.unfocus();
});
_login()
.then((_) {
loginData.value = LoginAccount.privateKeyHex(
_privateKey.privateKey!,
);
if (context.mounted) {
context.go("/");
}
})
.catchError((e) {
setState(() {
_loading = false;
_error = e is String ? e : e.toString();
});
});
},
);
},
),
if (_loading) CircularProgressIndicator(),
if (_error != null)
Text(
_error!,

View File

@ -22,6 +22,8 @@ class ProfilePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hexPubkey = bech32ToHex(pubkey);
final isMe = ndk.accounts.getPublicKey() == hexPubkey;
return ProfileLoaderWidget(hexPubkey, (ctx, state) {
final profile = state.data ?? Metadata(pubKey: hexPubkey);
return SingleChildScrollView(
@ -70,16 +72,24 @@ class ProfilePage extends StatelessWidget {
],
),
if (ndk.accounts.getPublicKey() == hexPubkey)
if (isMe)
Row(
spacing: 8,
children: [
BasicButton.text(
"Logout",
onTap: () {
loginData.logout();
ndk.accounts.logout();
context.go("/");
},
),
BasicButton.text(
"Edit Profile",
onTap: () {
context.push("/settings/profile");
},
),
],
),
Text(
@ -89,10 +99,9 @@ class ProfilePage extends StatelessWidget {
RxFilter<Nip01Event>(
Key("profile-streams:$hexPubkey"),
relays: defaultRelays,
filters: [
Filter(kinds: [30_311], limit: 200, pTags: [hexPubkey]),
Filter(kinds: [30_311], limit: 200, authors: [hexPubkey]),
Filter(kinds: [30_311], limit: 100, pTags: [hexPubkey]),
Filter(kinds: [30_311], limit: 100, authors: [hexPubkey]),
],
builder: (ctx, state) {
return StreamGrid(

View File

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:ndk/ndk.dart';
import 'package:zap_stream_flutter/main.dart';
import 'package:zap_stream_flutter/theme.dart';
import 'package:zap_stream_flutter/widgets/avatar_upload.dart';
import 'package:zap_stream_flutter/widgets/button.dart';
class SettingsProfilePage extends StatelessWidget {
final TextEditingController _picture = TextEditingController();
final TextEditingController _name = TextEditingController();
final TextEditingController _about = TextEditingController();
final TextEditingController _nip5 = TextEditingController();
final TextEditingController _lud16 = TextEditingController();
final ValueNotifier<bool> _loading = ValueNotifier(false);
SettingsProfilePage({super.key});
@override
Widget build(BuildContext context) {
final pubkey = ndk.accounts.getPublicKey();
if (pubkey == null) return Text("Cant edit profile when logged out");
return FutureBuilder(
future: ndk.metadata.loadMetadata(pubkey),
builder: (context, state) {
if (state.hasData) {
_name.text = state.data!.name ?? "";
_about.text = state.data!.about ?? "";
_nip5.text = state.data!.nip05 ?? "";
_lud16.text = state.data!.lud16 ?? "";
_picture.text = state.data!.picture ?? "";
}
return ValueListenableBuilder(
valueListenable: _loading,
builder: (context, v, _) {
return Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: AvatarUpload(
key: Key("avatar:${_picture.text}"),
avatar: _picture.text.isEmpty ? null : _picture.text,
onUpload: (i) {
_picture.text = i;
},
),
),
TextField(
controller: _name,
readOnly: v,
decoration: InputDecoration(
labelText: "Display Name",
fillColor: LAYER_1,
filled: true,
),
),
TextField(
controller: _about,
readOnly: v,
decoration: InputDecoration(
labelText: "About",
fillColor: LAYER_1,
filled: true,
),
),
TextField(
controller: _nip5,
readOnly: v,
decoration: InputDecoration(
labelText: "Nostr Address",
fillColor: LAYER_1,
filled: true,
),
),
TextField(
controller: _lud16,
readOnly: v,
decoration: InputDecoration(
labelText: "Lightning Address",
fillColor: LAYER_1,
filled: true,
),
),
BasicButton.text(
"Save",
disabled: v,
onTap: () async {
_loading.value = true;
try {
final newMeta = Metadata(
pubKey: pubkey,
name: _name.text.isEmpty ? null : _name.text,
about: _about.text.isEmpty ? null : _about.text,
picture: _picture.text.isEmpty ? null : _picture.text,
nip05: _nip5.text.isEmpty ? null : _nip5.text,
lud16: _lud16.text.isEmpty ? null : _lud16.text,
);
await ndk.metadata.broadcastMetadata(newMeta);
if (context.mounted) {
context.pop();
}
} finally {
_loading.value = false;
}
},
),
if (v) Center(child: CircularProgressIndicator()),
],
);
},
);
},
);
}
}

View File

@ -0,0 +1,106 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:zap_stream_flutter/main.dart';
import 'package:zap_stream_flutter/theme.dart';
class AvatarUpload extends StatefulWidget {
final String? avatar;
final Future<void> Function()? onUploadStart;
final void Function(String)? onUpload;
const AvatarUpload({
super.key,
this.onUpload,
this.onUploadStart,
this.avatar,
});
@override
State<StatefulWidget> createState() => _AvatarUpload();
}
class _AvatarUpload extends State<AvatarUpload> {
String? _avatar;
String? _error;
bool _loading = false;
@override
void initState() {
_avatar = widget.avatar;
super.initState();
}
Future<String?> _uploadAvatar() async {
if (widget.onUploadStart != null) {
await widget.onUploadStart!();
}
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
if (file != null) {
final upload = await ndk.blossom.uploadBlob(
serverUrls: ["https://nostr.download"],
data: await file.readAsBytes(),
);
final imgUrl = upload.first.descriptor!.url;
setState(() {
_avatar = imgUrl;
});
return imgUrl;
}
return null;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
_loading = true;
});
_uploadAvatar()
.then((u) {
setState(() {
_loading = false;
});
if (widget.onUpload != null && u != null) {
widget.onUpload!(u);
}
})
.catchError((e) {
setState(() {
_error = e is String ? e : e.toString();
_loading = false;
});
});
},
child: Column(
spacing: 8,
children: [
Container(
width: 200,
height: 200,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(200)),
color: Color.fromARGB(100, 50, 50, 50),
),
child:
_avatar == null
? Center(
child:
_loading
? CircularProgressIndicator()
: Text("Upload Avatar"),
)
: CachedNetworkImage(imageUrl: _avatar!),
),
if (_error != null)
Text(
_error!,
style: TextStyle(color: WARNING, fontWeight: FontWeight.bold),
),
],
),
);
}
}

View File

@ -61,7 +61,7 @@ class BasicButton extends StatelessWidget {
onTap!();
}
},
child: (disabled ?? false) ? Opacity(opacity: 0.3, child: inner) : inner,
child: (disabled ?? false) ? Opacity(opacity: 0.5, child: inner) : inner,
);
}
}