drifter_app/lib/pages/home_screen/home_screen_widget.dart

433 lines
16 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:drifter/domain_models/domain_models.dart';
import 'package:drifter/models/models.dart';
import 'package:drifter/theme/app_colors.dart';
import 'package:drifter/pages/home_screen/widgets/message_ok_button_widget.dart';
import 'package:drifter/pages/home_screen/widgets/message_text_button_widget.dart';
import 'package:drifter/pages/home_screen/widgets/message_text_form_field_widget.dart';
import 'package:drifter/pages/profile_screen/widgets/message_snack_bar.dart';
import 'package:nostr_tools/nostr_tools.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
bool _isConnected = false;
final List<Event> _events = [];
final Map<String, Metadata> _metaDatas = {};
late Stream<Event> _stream;
final _controller = StreamController<Event>();
bool _isNotePublishing = false;
@override
void initState() {
_initStream();
super.initState();
}
Future<Stream<Event>> _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<void> _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<String>? extractImage(String text) {
final RegExp exp = RegExp(
r"(http(s?):)([/|.|\w|\s|-])*\.(?:jpg|gif|png|jpeg)",
caseSensitive: false,
multiLine: true,
);
final Iterable<Match> matches = exp.allMatches(text);
final List<String> imageLinks =
matches.map((match) => match.group(0)!).toList();
return imageLinks.isNotEmpty ? imageLinks : null;
}
@override
Widget build(BuildContext context) {
final List<String>? imageLinks = extractImage(domain.content);
return Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(8),
),
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<CreatePost> createState() => _CreatePostState();
}
class _CreatePostState extends State<CreatePost> {
final _noteController = TextEditingController();
final GlobalKey<FormFieldState> _formKey = GlobalKey<FormFieldState>();
@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.background,
),
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),
],
)
],
),
),
);
}),
);
},
);
}
}