forked from alexvasl/drifter_app
441 lines
16 KiB
Dart
441 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/keys.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(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<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.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),
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|