diff --git a/lib/lib/constants/constants.dart b/lib/lib/constants/constants.dart new file mode 100644 index 0000000..574d308 --- /dev/null +++ b/lib/lib/constants/constants.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +String avatarUrl = "https://sweary.com/avatar-generator/"; +String imageSize = "size=200x200"; diff --git a/lib/lib/domain_models/domain_models.dart b/lib/lib/domain_models/domain_models.dart new file mode 100644 index 0000000..1f78fda --- /dev/null +++ b/lib/lib/domain_models/domain_models.dart @@ -0,0 +1,21 @@ +class Domain { + final String noteId; + final String avatarUrl; + final String name; + final String username; + final String time; + final String content; + final String pubkey; + final String? imageUrl; + + Domain({ + required this.noteId, + required this.avatarUrl, + required this.name, + required this.username, + required this.time, + required this.content, + required this.pubkey, + this.imageUrl, + }); +} diff --git a/lib/lib/main.dart b/lib/lib/main.dart new file mode 100644 index 0000000..fd22fb8 --- /dev/null +++ b/lib/lib/main.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/theme/app_colors.dart'; + +import 'package:drifter/widgets/main_screen/main_screen_widget.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + appBarTheme: const AppBarTheme(backgroundColor: AppColors.mainDarkBlue), + primarySwatch: Colors.blue, + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: AppColors.mainDarkBlue, + selectedItemColor: Colors.white, + unselectedItemColor: Colors.grey, + ), + ), + home: const MainScreenWidget(), + ); + } +} diff --git a/lib/lib/models/keys.dart b/lib/lib/models/keys.dart new file mode 100644 index 0000000..16ca934 --- /dev/null +++ b/lib/lib/models/keys.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; +import 'package:nostr_tools/nostr_tools.dart'; + +class Keys { + static String privateKey = ''; + static String publicKey = ''; + static bool keysExist = false; +} + +class Relay { + static final relay = RelayApi(relayUrl: 'wss://relay.damus.io'); +} diff --git a/lib/lib/theme/app_colors.dart b/lib/lib/theme/app_colors.dart new file mode 100644 index 0000000..1ba08a9 --- /dev/null +++ b/lib/lib/theme/app_colors.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; + +abstract class AppColors { + static const mainDarkBlue = Color.fromRGBO(3, 37, 65, 1); + static const mainLightBlue = Color.fromRGBO(48, 86, 117, 1); +} diff --git a/lib/lib/widgets/home_screen/home_screen_widget.dart b/lib/lib/widgets/home_screen/home_screen_widget.dart new file mode 100644 index 0000000..9e24614 --- /dev/null +++ b/lib/lib/widgets/home_screen/home_screen_widget.dart @@ -0,0 +1,442 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:drifter/domain_models/domain_models.dart'; +import 'package:drifter/models/keys.dart'; +import 'package:drifter/theme/app_colors.dart'; +import 'package:drifter/widgets/home_screen/home_screen_widgets/message_ok_button_widget.dart'; +import 'package:drifter/widgets/home_screen/home_screen_widgets/message_text_button_widget.dart'; +import 'package:drifter/widgets/home_screen/home_screen_widgets/message_text_form_field_widget.dart'; +import 'package:drifter/widgets/profile_screen/profile_screen_widgets/message_snack_bar.dart'; +import 'package:nostr_tools/nostr_tools.dart'; + +import '../profile_screen/profile_screen.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + bool _isConnected = false; + + final List _events = []; + final Map _metaDatas = {}; + late Stream _stream; + final _controller = StreamController(); + + bool _isNotePublishing = false; + + @override + void initState() { + _initStream(); + super.initState(); + } + + Future> _connectToRelay() async { + final stream = await Relay.relay.connect(); + + Relay.relay.on((event) { + if (event == RelayEvent.connect) { + setState(() => _isConnected = true); + } else if (event == RelayEvent.error) { + setState(() => _isConnected = false); + } + }); + + Relay.relay.sub([ + Filter( + kinds: [1], + limit: 100, + t: ['nostr'], + ) + ]); + + return stream + .where((message) => message.type == 'EVENT') + .map((message) => message.message); + } + + void _initStream() async { + _stream = await _connectToRelay(); + _stream.listen((message) { + final event = message; + if (event.kind == 1) { + setState(() => _events.add(event)); + Relay.relay.sub([ + Filter(kinds: [0], authors: [event.pubkey]) + ]); + } else if (event.kind == 0) { + final metadata = Metadata.fromJson(jsonDecode(event.content)); + setState(() => _metaDatas[event.pubkey] = metadata); + } + _controller.add(event); + }); + } + +// The _resubscribeStream() method clears the _events and _metaData scollection after a 1 second delay + Future _resubscribeStream() async { + await Future.delayed(const Duration(seconds: 1), () { + setState(() { + _events.clear(); + _metaDatas.clear(); + }); + // _initStream() method, responsible for initializing and subscribing to the stream, to reconnect and re-subscribe to the filter. + _initStream(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: RefreshIndicator( + onRefresh: () async { + await _resubscribeStream(); + }, + child: StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) { + // Inside the builder callback, the snapshot object contains the most recent event from the thread. + // If snapshot.hasData is true, there is data to display. In this case, ListView.builder is returned, which displays a list of NoostCard widgets. + if (snapshot.hasData) { + return ListView.builder( + // The itemCount property of ListView.builder is set to _events.length, , which is the number of events in the _events list. + itemCount: _events.length, + itemBuilder: (context, index) { + final event = _events[index]; + final metadata = _metaDatas[event.pubkey]; + // For each event, a Noost object is created that encapsulates the details of the event, including id, avatarUrl, name,username, time, content и pubkey. + // _metaDatas, you can map the event public key to the author's metadata. + final domain = Domain( + noteId: event.id, + avatarUrl: metadata?.picture ?? + 'https://robohash.org/${event.pubkey}', + name: metadata?.name ?? 'Anon', + username: metadata?.displayName ?? + (metadata?.display_name ?? 'Anon'), + time: TimeAgo.format(event.created_at), + content: event.content, + pubkey: event.pubkey, + ); + return DomainCard(domain: domain); + }, + ); + } else if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: Text('Loading....')); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + return const CenteredCircularProgressIndicator(); + }, + ), + ), + floatingActionButton: Keys.keysExist + ? CreatePost( + // The publishNote function is called when the user launches the "Noost!" button in the dialog box. + publishNote: (note) { + setState(() => _isNotePublishing = true); + + // EventApi Creates an instance of the class defined in the nostr_tools package. + final eventApi = EventApi(); + // The finishEvent method of the EventApi class is called with the Event object and the _privateKey variable. + // finishEvent will set the event id with the event hash and sign the event with the given _privateKey. + final event = eventApi.finishEvent( + // This creates a new instance of the Event class with certain properties, such as: + Event( + kind: 1, + tags: [ + ['t', 'nostr'] + ], + content: note!, + created_at: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + Keys.publicKey, + ); + if (eventApi.verifySignature(event)) { + try { + // If the signature is verified, the _relay method is called for the object to publish the event. + Relay.relay.publish(event); + // After the _resubscribeStream event is published, a method is called that will probably update the stream or subscription to reflect the recently published event. + _resubscribeStream(); + // Show SnackBar to display a message that the note has been successfully published. + ScaffoldMessenger.of(context).showSnackBar( + MessageSnackBar( + label: 'Congratulations! Noost Published!'), + ); + } catch (_) { + // If an error occurs during the publishing process (e.g., an exception is caught), SnackBar displays a warning instead. + ScaffoldMessenger.of(context).showSnackBar(MessageSnackBar( + label: 'Oops! Something went wrong!', + isWarning: true, + )); + } + } + setState(() => _isNotePublishing = false); + Navigator.pop(context); + }, + isNotePublishing: _isNotePublishing, + ) + : + // If _keysExist is false, then an empty widget is displayed, which means that the FAB will not be visible. Container() + Container(), + ); + } +} + +class TimeAgo { + static String format(int timestamp) { + DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + Duration difference = DateTime.now().difference(dateTime); + + String timeAgo = ''; + + if (difference.inDays > 0) { + timeAgo = + '${difference.inDays} ${difference.inDays == 1 ? 'day' : 'days'} ago'; + } else if (difference.inHours > 0) { + timeAgo = + '${difference.inHours} ${difference.inHours == 1 ? 'hour' : 'hours'} ago'; + } else if (difference.inMinutes > 0) { + timeAgo = + '${difference.inMinutes} ${difference.inMinutes == 1 ? 'minute' : 'minutes'} ago'; + } else { + timeAgo = 'just now'; + } + + return timeAgo; + } +} + +class DomainCard extends StatelessWidget { + const DomainCard({ + super.key, + required this.domain, + }); + + final Domain domain; + + List? extractImage(String text) { + final RegExp exp = RegExp( + r"(http(s?):)([/|.|\w|\s|-])*\.(?:jpg|gif|png|jpeg)", + caseSensitive: false, + multiLine: true, + ); + + final Iterable matches = exp.allMatches(text); + + final List imageLinks = + matches.map((match) => match.group(0)!).toList(); + + return imageLinks.isNotEmpty ? imageLinks : null; + } + + @override + Widget build(BuildContext context) { + final List? imageLinks = extractImage(domain.content); + return Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.mainLightBlue, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + spreadRadius: 2, + blurRadius: 5, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: CircleAvatar( + backgroundImage: FadeInImage( + placeholder: + const NetworkImage('https://i.ibb.co/mJkxDkb/satoshi.png'), + image: NetworkImage(domain.avatarUrl), + ).image, + ), + title: + Text(domain.name, style: const TextStyle(color: Colors.white)), + subtitle: Text('@${domain.username.toLowerCase()} • ${domain.time}', + style: TextStyle(color: Colors.grey.shade400)), + trailing: const Icon(Icons.more_vert, color: Colors.grey), + ), + Divider(height: 1, color: Colors.grey.shade400), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text(domain.content, + style: const TextStyle(color: Colors.white)), + ), + if (imageLinks != null && imageLinks.isNotEmpty) + Center( + child: Stack( + children: [ + const Placeholder( + fallbackHeight: 200, + color: Colors.transparent, + ), + Center( + child: FadeInImage( + placeholder: const NetworkImage( + 'https://i.ibb.co/D9jqXgR/58038897-167f0280-7ae6-11e9-94eb-88e880a25f0f.gif', + ), + image: NetworkImage(imageLinks.first), + fit: BoxFit.cover, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class CenteredCircularProgressIndicator extends StatelessWidget { + const CenteredCircularProgressIndicator({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Center( + child: CircularProgressIndicator(), + ); + } +} + +class CreatePost extends StatefulWidget { + const CreatePost({ + Key? key, + required this.publishNote, + required this.isNotePublishing, + }) : super(key: key); + + final Function(String?) publishNote; + final bool isNotePublishing; + + @override + State createState() => _CreatePostState(); +} + +class _CreatePostState extends State { + final _noteController = TextEditingController(); + final GlobalKey _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + backgroundColor: Colors.deepPurpleAccent, + tooltip: 'Create a new post', + elevation: 2, + highlightElevation: 4, + foregroundColor: Colors.white, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 60, + height: 60, + decoration: const BoxDecoration( + shape: BoxShape.circle, color: AppColors.mainDarkBlue), + ), + const Icon( + Icons.add, + color: Colors.white, + ), + ], + ), + onPressed: () async { + _noteController.clear(); + await showDialog( + barrierDismissible: false, + context: context, + builder: ((context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: AppColors.mainDarkBlue, + ), + child: const Center( + child: Text( + 'Create a Noost', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: MessageTextFormField( + maxLines: 5, + hintText: 'Type your Noost here...', + controller: _noteController, + formKey: _formKey, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter your note.'; + } + return null; + }, + ), + ), + const SizedBox(height: 24), + widget.isNotePublishing + ? const CenteredCircularProgressIndicator() + : Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + MessageTextButton( + onPressed: () { + Navigator.pop(context); + }, + label: 'Cancel', + ), + const SizedBox(width: 16), + MessageOkButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + widget.publishNote( + _noteController.text.trim()); + } else { + _formKey.currentState?.setState(() {}); + } + }, + label: 'Noost!', + ), + const SizedBox(width: 24), + ], + ) + ], + ), + ), + ); + }), + ); + }, + ); + } +} diff --git a/lib/lib/widgets/home_screen/home_screen_widgets/message_ok_button_widget.dart b/lib/lib/widgets/home_screen/home_screen_widgets/message_ok_button_widget.dart new file mode 100644 index 0000000..5585bcd --- /dev/null +++ b/lib/lib/widgets/home_screen/home_screen_widgets/message_ok_button_widget.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/theme/app_colors.dart'; + +class MessageOkButton extends StatelessWidget { + const MessageOkButton({ + super.key, + required this.onPressed, + required this.label, + }); + + final void Function()? onPressed; + final String label; + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.mainDarkBlue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ); + } +} diff --git a/lib/lib/widgets/home_screen/home_screen_widgets/message_text_button_widget.dart b/lib/lib/widgets/home_screen/home_screen_widgets/message_text_button_widget.dart new file mode 100644 index 0000000..252ac8d --- /dev/null +++ b/lib/lib/widgets/home_screen/home_screen_widgets/message_text_button_widget.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/theme/app_colors.dart'; + +class MessageTextButton extends StatelessWidget { + const MessageTextButton({ + super.key, + required this.onPressed, + required this.label, + this.color, + }); + + final void Function()? onPressed; + final String label; + final Color? color; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed, + child: Text( + label, + style: TextStyle( + color: AppColors.mainDarkBlue, + ), + ), + ); + } +} diff --git a/lib/lib/widgets/home_screen/home_screen_widgets/message_text_form_field_widget.dart b/lib/lib/widgets/home_screen/home_screen_widgets/message_text_form_field_widget.dart new file mode 100644 index 0000000..8b51e48 --- /dev/null +++ b/lib/lib/widgets/home_screen/home_screen_widgets/message_text_form_field_widget.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/theme/app_colors.dart'; + +class MessageTextFormField extends StatelessWidget { + const MessageTextFormField({ + super.key, + required this.hintText, + required this.controller, + required this.formKey, + required this.validator, + this.maxLines, + }); + + final String hintText; + final TextEditingController controller; + final Key formKey; + final String? Function(String?)? validator; + final int? maxLines; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + key: formKey, + validator: validator, + maxLines: maxLines, + style: const TextStyle(color: Colors.black), + decoration: InputDecoration( + hintText: hintText, + hintStyle: const TextStyle(color: Colors.black54), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.black54), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.black54), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.mainDarkBlue), + ), + ), + ); + } +} diff --git a/lib/lib/widgets/main_screen/main_screen_widget.dart b/lib/lib/widgets/main_screen/main_screen_widget.dart new file mode 100644 index 0000000..a6c02ec --- /dev/null +++ b/lib/lib/widgets/main_screen/main_screen_widget.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/widgets/home_screen/home_screen_widget.dart'; +import 'package:drifter/widgets/message_screen/message_screen_widget.dart'; +import 'package:drifter/widgets/profile_screen/profile_screen.dart'; + +class MainScreenWidget extends StatefulWidget { + const MainScreenWidget({super.key}); + + @override + State createState() => _MainScreenWidgetState(); +} + +class _MainScreenWidgetState extends State { + int _selectedTap = 0; + + void onSelectedtap(int index) { + if (_selectedTap == index) return; + setState(() { + _selectedTap = index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Drifter'), + centerTitle: true, + ), + body: IndexedStack( + index: _selectedTap, + children: [ + HomeScreen(), + // MessageScreen(), + ProfileScreen(), + ], + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _selectedTap, + items: [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Home', + ), + // BottomNavigationBarItem( + // icon: Icon(Icons.message), + // label: 'Message', + // ), + BottomNavigationBarItem( + icon: Icon(Icons.person), + label: 'Profile', + ), + ], + onTap: onSelectedtap, + ), + ); + } +} diff --git a/lib/lib/widgets/message_screen/message_screen_widget.dart b/lib/lib/widgets/message_screen/message_screen_widget.dart new file mode 100644 index 0000000..507672d --- /dev/null +++ b/lib/lib/widgets/message_screen/message_screen_widget.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/theme/app_colors.dart'; + +class MessageScreen extends StatefulWidget { + const MessageScreen({super.key}); + + @override + State createState() => _MessageScreenState(); +} + +class _MessageScreenState extends State { + @override + Widget build(BuildContext context) { + return Stack( + children: [ + ListView( + padding: EdgeInsets.all(16), + children: [ + Center( + child: Text('List of posts'), + ) + ], + ), + MessageInput(), + ], + ); + } +} + +class MessageInput extends StatefulWidget { + const MessageInput({ + super.key, + }); + + @override + State createState() => _MessageInputState(); +} + +class _MessageInputState extends State { + final messageController = TextEditingController(); + + void submitData() { + final newMessage = messageController; + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.only(bottom: 16, left: 8, right: 8, top: 8), + child: Row( + children: [ + GestureDetector( + onTap: () {}, + child: const Icon( + Icons.add_a_photo, + color: AppColors.mainDarkBlue, + size: 30, + ), + ), + const SizedBox( + width: 8, + ), + Expanded( + child: TextField( + style: const TextStyle(fontSize: 20), + decoration: InputDecoration( + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(20))), + hintText: 'What\'s new?', + hintStyle: const TextStyle(fontSize: 20), + suffixIcon: IconButton( + icon: const Icon( + Icons.send, + color: AppColors.mainDarkBlue, + size: 30, + ), + onPressed: () { + submitData(); + }, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class NewMessage extends StatefulWidget { + const NewMessage({super.key}); + + @override + State createState() => _NewMessageState(); +} + +class _NewMessageState extends State { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/lib/lib/widgets/profile_screen/profile_screen.dart b/lib/lib/widgets/profile_screen/profile_screen.dart new file mode 100644 index 0000000..d89629d --- /dev/null +++ b/lib/lib/widgets/profile_screen/profile_screen.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:drifter/models/keys.dart'; +import 'package:drifter/theme/app_colors.dart'; +import 'package:nostr_tools/nostr_tools.dart'; + +import 'profile_screen_widgets/delete_keys_dialog.dart'; +import 'profile_screen_widgets/key_exist_dialog.dart'; +import 'profile_screen_widgets/keys_option_modal_bottom_sheet.dart'; +import 'profile_screen_widgets/message_snack_bar.dart'; +import 'profile_screen_widgets/user_info_widget.dart'; + +class ProfileScreen extends StatefulWidget { + const ProfileScreen({super.key}); + + @override + State createState() => ProfileScreenState(); +} + +class ProfileScreenState extends State { + final _secureStorage = const FlutterSecureStorage(); + + TextEditingController privateKeyInput = TextEditingController(); + TextEditingController publicKeyInput = TextEditingController(); + TextEditingController relayInput = TextEditingController(); + + final keyGenerator = KeyApi(); + final nip19 = Nip19(); + + void initState() { + _getKeysFromStorage(); + super.initState(); + } + + Future generateNewKeys() async { + final newPrivateKey = keyGenerator.generatePrivateKey(); + final nsec = nip19.nsecEncode(newPrivateKey); + final nsecDecoded = nip19.decode(nsec); + assert(nsecDecoded['type'] == 'nsec'); + assert(nsecDecoded['data'] == newPrivateKey); + + final newPublicKey = keyGenerator.getPublicKey(newPrivateKey); + final npub = nip19.npubEncode(newPublicKey); + final npubDecoded = nip19.decode(npub); + assert(npubDecoded['type'] == 'npub'); + assert(npubDecoded['data'] == newPublicKey); + return await _addKeyToStorage(nsec, npub); + } + + Future _getKeysFromStorage() async { + // Reading values associated with the " privateKey " and " publicKey " keys from a secure repository + final storedPrivateKey = await _secureStorage.read(key: 'privateKey'); + final storedPublicKey = await _secureStorage.read(key: 'publicKey'); + // Indicates that both private and public keys are stored in a secure repository, after which, the state variables are updated + if (storedPrivateKey != null && storedPublicKey != null) { + setState(() { + Keys.privateKey = storedPrivateKey; + Keys.publicKey = storedPublicKey; + Keys.keysExist = true; + }); + } + } + + // Adding a new key +// Writing a private and public key to a secure vault + Future _addKeyToStorage( + String privateKeyHex, + String publicKeyHex, + ) async { +// Waiting for both write operations to complete + Future.wait([ + _secureStorage.write(key: 'privateKey', value: privateKeyHex), + _secureStorage.write(key: 'publicKey', value: privateKeyHex), + ]); + + // Updating status variables and starting widget rebuilding + setState(() { + Keys.privateKey = privateKeyHex; + Keys.publicKey = publicKeyHex; + Keys.keysExist = true; + }); + +// Returns a boolean value indicating whether the keys were successfully added to the repository or not. + return Keys.keysExist; + } + + Future _deleteKeysStorage() async { + // Calling secure storage to remove keys from storage + Future.wait([ + _secureStorage.delete(key: 'privateKey'), + _secureStorage.delete(key: 'publicKey'), + ]); + + // Updating status variables, resetting values after deleting keys from the repository + setState(() { + Keys.privateKey = ''; + Keys.publicKey = ''; + Keys.keysExist = false; + }); + } + + @override + Widget build(BuildContext context) { + privateKeyInput.text = Keys.privateKey; + publicKeyInput.text = Keys.publicKey; + relayInput.text = Relay.relay.relayUrl; + + return ListView( + children: [ + SizedBox( + height: 60, + ), + UserInfo(), + SizedBox( + height: 40, + ), + FormKeys(), + SizedBox(height: 20), + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Keys.keysExist + ? ElevatedButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + AppColors.mainDarkBlue)), + onPressed: () { + keysExistDialog( + nip19.npubEncode(Keys.publicKey), + nip19.nsecEncode(Keys.privateKey), + ); + }, + child: Text( + 'Keys', + ), + ) + : ElevatedButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + AppColors.mainDarkBlue)), + onPressed: () { + modalBottomSheet(); + }, + child: Text( + 'Generate Keys', + ), + ), + Keys.keysExist + ? Row( + children: [ + IconButton( + onPressed: () { + deleteKeysDialog(); + }, + icon: const Icon(Icons.delete)), + ], + ) + : Container(), + ], + ), + ) + ], + ); + } + + Form FormKeys() { + return Form( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextFormField( + controller: privateKeyInput, + // _toHex ? widget.hexPriv : widget.nsecEncoded, + + decoration: const InputDecoration( + labelText: 'Private Key', + border: OutlineInputBorder(), + ), + ), + const SizedBox( + height: 20, + ), + TextFormField( + controller: publicKeyInput, + // _toHex ? widget.hexPub : widget.npubEncoded, + decoration: const InputDecoration( + labelText: 'Public Key', + border: OutlineInputBorder(), + ), + ), + const SizedBox( + height: 40, + ), + TextFormField( + controller: relayInput, + decoration: const InputDecoration( + labelText: 'Relay', + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + ); + } + + void modalBottomSheet() { + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return KeysOptionModalBottomSheet( + generateNewKeyPressed: () { + final currentContext = context; + generateNewKeys().then( + (keysGenerated) { + if (keysGenerated) { + ScaffoldMessenger.of(currentContext).showSnackBar( + MessageSnackBar(label: 'Keys Generated!')); + } + }, + ); + Navigator.pop(context); + }, + ); + }); + } + + void keysExistDialog(String npubEncode, String nsecEncode) async { + await showDialog( + context: context, + builder: ((context) { + return KeysExistDialog( + npubEncoded: npubEncode, + nsecEncoded: nsecEncode, + hexPriv: Keys.privateKey, + hexPub: Keys.publicKey, + ); + }), + ); + } + + void deleteKeysDialog() async { + await showDialog( + context: context, + builder: ((context) { + return DeleteKeysDialog( + onNoPressed: () { + Navigator.pop(context); + }, + onYesPressed: () { + final currentContext = context; + _deleteKeysStorage().then((_) { + if (!Keys.keysExist) { + ScaffoldMessenger.of(currentContext).showSnackBar( + MessageSnackBar( + label: 'Keys successfully deleted!', + isWarning: true, + ), + ); + } + }); + Navigator.pop(context); + }, + ); + }), + ); + } +} diff --git a/lib/lib/widgets/profile_screen/profile_screen_widgets/delete_keys_dialog.dart b/lib/lib/widgets/profile_screen/profile_screen_widgets/delete_keys_dialog.dart new file mode 100644 index 0000000..8e4874f --- /dev/null +++ b/lib/lib/widgets/profile_screen/profile_screen_widgets/delete_keys_dialog.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/theme/app_colors.dart'; + +import 'ok_button_widget.dart'; + +class DeleteKeysDialog extends StatelessWidget { + const DeleteKeysDialog({ + super.key, + required this.onNoPressed, + required this.onYesPressed, + }); + + final void Function()? onNoPressed; + final void Function()? onYesPressed; + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.redAccent, + ), + child: const Center( + child: Text( + 'Delete Keys!', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Do you want to delete your keys?', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: onNoPressed, + child: Text( + 'On', + style: TextStyle(color: AppColors.mainDarkBlue), + ), + ), + OkButton( + onPressed: onYesPressed, + label: 'YES', + ), + ], + ), + ], + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/lib/widgets/profile_screen/profile_screen_widgets/generated_keys.dart b/lib/lib/widgets/profile_screen/profile_screen_widgets/generated_keys.dart new file mode 100644 index 0000000..4049374 --- /dev/null +++ b/lib/lib/widgets/profile_screen/profile_screen_widgets/generated_keys.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +import 'ok_button_widget.dart'; + +class GeneratedKeys extends StatefulWidget { + const GeneratedKeys({ + super.key, + required this.npubEncoded, + required this.nsecEncoded, + required this.hexPriv, + required this.hexPub, + }); + + final String npubEncoded; + final String nsecEncoded; + final String hexPriv; + final String hexPub; + + @override + State createState() => _GeneratedKeysState(); +} + +class _GeneratedKeysState extends State { + bool _toHex = false; + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.indigo, + ), + child: const Center( + child: Text( + 'Keys', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Public Key', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 12), + SelectableText( + _toHex ? widget.hexPub : widget.npubEncoded, + style: TextStyle( + fontSize: 16, + color: Colors.grey[800], + ), + ), + const SizedBox(height: 24), + Text( + 'Private Key', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 12), + SelectableText( + _toHex ? widget.hexPriv : widget.nsecEncoded, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.redAccent, + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment + .spaceBetween, // Changed to space between to create space for icon buttons + children: [ + IconButton( + onPressed: () { + setState(() { + _toHex = !_toHex; + }); + }, + icon: const Icon(Icons.autorenew_outlined), + color: Colors.grey[700], + ), + OkButton( + onPressed: () { + Navigator.pop(context); + }, + label: 'OK', + ), + ], + ) + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/lib/widgets/profile_screen/profile_screen_widgets/key_exist_dialog.dart b/lib/lib/widgets/profile_screen/profile_screen_widgets/key_exist_dialog.dart new file mode 100644 index 0000000..d74e34f --- /dev/null +++ b/lib/lib/widgets/profile_screen/profile_screen_widgets/key_exist_dialog.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/theme/app_colors.dart'; + +import 'ok_button_widget.dart'; + +class KeysExistDialog extends StatefulWidget { + const KeysExistDialog({ + super.key, + required this.npubEncoded, + required this.nsecEncoded, + required this.hexPriv, + required this.hexPub, + }); + + final String npubEncoded; + final String nsecEncoded; + final String hexPriv; + final String hexPub; + + @override + State createState() => _KeysExistDialogState(); +} + +class _KeysExistDialogState extends State { + bool _toHex = false; + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: AppColors.mainDarkBlue, + ), + child: const Center( + child: Text( + 'Keys', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Public Key', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 12), + SelectableText( + _toHex ? widget.hexPub : widget.npubEncoded, + style: TextStyle( + fontSize: 16, + color: Colors.grey[800], + ), + ), + const SizedBox(height: 24), + Text( + 'Private Key', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 12), + SelectableText( + _toHex ? widget.hexPriv : widget.nsecEncoded, + style: TextStyle( + fontSize: 16, + color: Colors.grey[800], + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment + .spaceBetween, // Changed to space between to create space for icon buttons + children: [ + IconButton( + onPressed: () { + setState(() { + _toHex = !_toHex; + }); + }, + icon: const Icon(Icons.autorenew_outlined), + color: Colors.grey[700], + ), + OkButton( + onPressed: () { + Navigator.pop(context); + }, + label: 'OK', + ), + ], + ) + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/lib/widgets/profile_screen/profile_screen_widgets/keys_option_modal_bottom_sheet.dart b/lib/lib/widgets/profile_screen/profile_screen_widgets/keys_option_modal_bottom_sheet.dart new file mode 100644 index 0000000..25ad2e5 --- /dev/null +++ b/lib/lib/widgets/profile_screen/profile_screen_widgets/keys_option_modal_bottom_sheet.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/theme/app_colors.dart'; + +class KeysOptionModalBottomSheet extends StatelessWidget { + const KeysOptionModalBottomSheet({ + super.key, + required this.generateNewKeyPressed, + }); + + final void Function()? generateNewKeyPressed; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: AppColors.mainDarkBlue), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(Colors.white)), + onPressed: generateNewKeyPressed, + child: Text( + 'Generate New Key', + style: TextStyle(color: AppColors.mainDarkBlue), + ), + ), + const SizedBox(height: 10), + ], + ), + ); + } +} diff --git a/lib/lib/widgets/profile_screen/profile_screen_widgets/message_snack_bar.dart b/lib/lib/widgets/profile_screen/profile_screen_widgets/message_snack_bar.dart new file mode 100644 index 0000000..d5c4fd1 --- /dev/null +++ b/lib/lib/widgets/profile_screen/profile_screen_widgets/message_snack_bar.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class MessageSnackBar extends SnackBar { + MessageSnackBar({Key? key, required this.label, this.isWarning = false}) + : super( + key: key, + content: _GenericErrorSnackBarMessage( + label: label, + isWarning: isWarning, + ), + backgroundColor: isWarning! ? Colors.red : Colors.white, + elevation: 6.0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + behavior: SnackBarBehavior.fixed, + ); + + final String label; + final bool? isWarning; +} + +class _GenericErrorSnackBarMessage extends StatelessWidget { + const _GenericErrorSnackBarMessage({ + Key? key, + required this.label, + this.isWarning, + }) : super(key: key); + + final String label; + final bool? isWarning; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text( + label, + style: TextStyle( + color: isWarning! ? Colors.white : Colors.black, + fontSize: 16.0, + ), + ), + ); + } +} diff --git a/lib/lib/widgets/profile_screen/profile_screen_widgets/ok_button_widget.dart b/lib/lib/widgets/profile_screen/profile_screen_widgets/ok_button_widget.dart new file mode 100644 index 0000000..6dbaad7 --- /dev/null +++ b/lib/lib/widgets/profile_screen/profile_screen_widgets/ok_button_widget.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:drifter/theme/app_colors.dart'; + +class OkButton extends StatelessWidget { + const OkButton({ + super.key, + required this.onPressed, + required this.label, + }); + + final void Function()? onPressed; + final String label; + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.mainDarkBlue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ); + } +} diff --git a/lib/lib/widgets/profile_screen/profile_screen_widgets/user_info_widget.dart b/lib/lib/widgets/profile_screen/profile_screen_widgets/user_info_widget.dart new file mode 100644 index 0000000..8e28069 --- /dev/null +++ b/lib/lib/widgets/profile_screen/profile_screen_widgets/user_info_widget.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class UserInfo extends StatelessWidget { + const UserInfo({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + child: Column( + children: [ + AvatarWidget(), + SizedBox( + height: 10, + ), + UserNameWidget(), + ], + ), + ); + } +} + +class AvatarWidget extends StatelessWidget { + const AvatarWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.all(Radius.circular(75))), + width: 150, + height: 150, + ); + } +} + +class UserNameWidget extends StatelessWidget { + const UserNameWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.center, + child: Text( + 'Username', + style: TextStyle(fontSize: 25), + ), + ); + } +} diff --git a/lib/models/keys.dart b/lib/models/keys.dart index c0193f6..16ca934 100644 --- a/lib/models/keys.dart +++ b/lib/models/keys.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:nostr_tools/nostr_tools.dart'; class Keys { static String privateKey = ''; static String publicKey = ''; static bool keysExist = false; } + +class Relay { + static final relay = RelayApi(relayUrl: 'wss://relay.damus.io'); +} diff --git a/lib/widgets/home_screen/home_screen_widget.dart b/lib/widgets/home_screen/home_screen_widget.dart index 8ebf2f1..9e24614 100644 --- a/lib/widgets/home_screen/home_screen_widget.dart +++ b/lib/widgets/home_screen/home_screen_widget.dart @@ -22,7 +22,7 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { bool _isConnected = false; - final _relay = RelayApi(relayUrl: 'wss://relay.damus.io'); + final List _events = []; final Map _metaDatas = {}; late Stream _stream; @@ -37,9 +37,9 @@ class _HomeScreenState extends State { } Future> _connectToRelay() async { - final stream = await _relay.connect(); + final stream = await Relay.relay.connect(); - _relay.on((event) { + Relay.relay.on((event) { if (event == RelayEvent.connect) { setState(() => _isConnected = true); } else if (event == RelayEvent.error) { @@ -47,7 +47,7 @@ class _HomeScreenState extends State { } }); - _relay.sub([ + Relay.relay.sub([ Filter( kinds: [1], limit: 100, @@ -66,7 +66,7 @@ class _HomeScreenState extends State { final event = message; if (event.kind == 1) { setState(() => _events.add(event)); - _relay.sub([ + Relay.relay.sub([ Filter(kinds: [0], authors: [event.pubkey]) ]); } else if (event.kind == 0) { @@ -153,12 +153,12 @@ class _HomeScreenState extends State { content: note!, created_at: DateTime.now().millisecondsSinceEpoch ~/ 1000, ), - Keys.privateKey, + Keys.publicKey, ); if (eventApi.verifySignature(event)) { try { // If the signature is verified, the _relay method is called for the object to publish the event. - _relay.publish(event); + Relay.relay.publish(event); // After the _resubscribeStream event is published, a method is called that will probably update the stream or subscription to reflect the recently published event. _resubscribeStream(); // Show SnackBar to display a message that the note has been successfully published. diff --git a/lib/widgets/profile_screen/profile_screen.dart b/lib/widgets/profile_screen/profile_screen.dart index 6c91444..d89629d 100644 --- a/lib/widgets/profile_screen/profile_screen.dart +++ b/lib/widgets/profile_screen/profile_screen.dart @@ -8,7 +8,6 @@ import 'profile_screen_widgets/delete_keys_dialog.dart'; import 'profile_screen_widgets/key_exist_dialog.dart'; import 'profile_screen_widgets/keys_option_modal_bottom_sheet.dart'; import 'profile_screen_widgets/message_snack_bar.dart'; -import 'profile_screen_widgets/ok_button_widget.dart'; import 'profile_screen_widgets/user_info_widget.dart'; class ProfileScreen extends StatefulWidget { @@ -23,6 +22,7 @@ class ProfileScreenState extends State { TextEditingController privateKeyInput = TextEditingController(); TextEditingController publicKeyInput = TextEditingController(); + TextEditingController relayInput = TextEditingController(); final keyGenerator = KeyApi(); final nip19 = Nip19(); @@ -44,7 +44,7 @@ class ProfileScreenState extends State { final npubDecoded = nip19.decode(npub); assert(npubDecoded['type'] == 'npub'); assert(npubDecoded['data'] == newPublicKey); - return await _addKeyToStorage(newPrivateKey, newPublicKey); + return await _addKeyToStorage(nsec, npub); } Future _getKeysFromStorage() async { @@ -103,6 +103,7 @@ class ProfileScreenState extends State { Widget build(BuildContext context) { privateKeyInput.text = Keys.privateKey; publicKeyInput.text = Keys.publicKey; + relayInput.text = Relay.relay.relayUrl; return ListView( children: [ @@ -178,7 +179,6 @@ class ProfileScreenState extends State { labelText: 'Private Key', border: OutlineInputBorder(), ), - maxLength: 64, ), const SizedBox( height: 20, @@ -195,6 +195,7 @@ class ProfileScreenState extends State { height: 40, ), TextFormField( + controller: relayInput, decoration: const InputDecoration( labelText: 'Relay', border: OutlineInputBorder(),