forked from jeena/FeedTheMonkey
app: implement Epics 2–10
Add the full application logic on top of the Epic 1 skeleton:
Epic 2 — Authentication
- LoginDialog (AdwDialog, Blueprint template) with server URL,
username, and password fields; emits logged-in signal on submit
- credentials.rs: store/load/clear via libsecret (password_store_sync /
password_search_sync / password_clear_sync, v0_19 feature)
- api.rs: Api::login() parses Auth= token from ClientLogin response;
fetch_write_token() fetches the write token
- Auto-login on startup from stored credentials; logout with
AdwAlertDialog confirmation; login errors shown in AdwAlertDialog
Epic 3 — Article fetching
- model.rs: Article struct and ArticleObject GObject wrapper with
unread property for list store binding
- Api::fetch_unread() deserializes Google Reader JSON, derives unread
from categories, generates plain-text excerpt
- Sidebar uses a GtkStack with placeholder / loading / empty / error /
list pages; AdwSpinnerPaintable while fetching; Try Again button
Epic 4 — Sidebar
- article_row.blp: composite template with feed title, date, title,
and excerpt labels
- ArticleRow GObject subclass; binds ArticleObject, watches unread
notify to apply .dim-label on the title; relative timestamp format
Epic 5 — Content pane
- content.html updated: setArticle(), checkKey(), feedthemonkey: URI
navigation scheme; dark mode via prefers-color-scheme
- content.css: proper article layout, dark mode, code blocks
- WebView loaded from GResource; decide-policy intercepts
feedthemonkey:{next,previous,open} and all external links
Epic 6 — Read state
- Api::mark_read() / mark_unread() via edit-tag endpoint
- Optimistic unread toggle on ArticleObject; background API calls;
mark_unread_guard prevents re-marking on navigation
- AdwToast shown on mark-unread
Epic 7 — Keyboard shortcuts
- GtkShortcutController on window for all shortcuts from the backlog
- shortcuts.blp: AdwShortcutsWindow documenting all shortcuts
- F1 opens shortcuts dialog; Ctrl+W closes window; Ctrl+Q quits
Epic 8 — Zoom
- zoom_in/zoom_out/zoom_reset wired to Ctrl+±/0; zoom level saved to
and restored from GSettings zoom-level key
Epic 9 — Window state persistence
- Window width/height/maximized saved on close, restored on open
- (Sidebar width deferred — AdwNavigationSplitView fraction binding)
Epic 10 — Polish
- AdwAboutDialog with app name, version, GPL-3.0, website
- Logout confirmation AdwAlertDialog with destructive button
- Win.toggle-fullscreen action (F11)
- Api dropped on window close to cancel in-flight requests
This commit is contained in:
parent
8db0b16954
commit
813dda3579
22 changed files with 1838 additions and 42 deletions
132
src/article_row.rs
Normal file
132
src/article_row.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use gtk4::glib;
|
||||
use gtk4::subclass::prelude::ObjectSubclassIsExt;
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct ArticleRow(ObjectSubclass<imp::ArticleRow>)
|
||||
@extends gtk4::Box, gtk4::Widget,
|
||||
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable;
|
||||
}
|
||||
|
||||
impl ArticleRow {
|
||||
pub fn new() -> Self {
|
||||
glib::Object::new()
|
||||
}
|
||||
|
||||
pub fn bind(&self, obj: &crate::model::ArticleObject) {
|
||||
self.imp().bind(obj);
|
||||
}
|
||||
|
||||
pub fn unbind(&self) {
|
||||
self.imp().unbind();
|
||||
}
|
||||
}
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
use crate::model::ArticleObject;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::subclass::prelude::*;
|
||||
use gtk4::CompositeTemplate;
|
||||
use glib::object::ObjectExt;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[derive(CompositeTemplate, Default)]
|
||||
#[template(resource = "/net/jeena/FeedTheMonkey/ui/article_row.ui")]
|
||||
pub struct ArticleRow {
|
||||
#[template_child]
|
||||
pub feed_title_label: TemplateChild<gtk4::Label>,
|
||||
#[template_child]
|
||||
pub date_label: TemplateChild<gtk4::Label>,
|
||||
#[template_child]
|
||||
pub title_label: TemplateChild<gtk4::Label>,
|
||||
#[template_child]
|
||||
pub excerpt_label: TemplateChild<gtk4::Label>,
|
||||
|
||||
pub bindings: RefCell<Vec<glib::Binding>>,
|
||||
pub unread_handler: RefCell<Option<(ArticleObject, glib::SignalHandlerId)>>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for ArticleRow {
|
||||
const NAME: &'static str = "ArticleRow";
|
||||
type Type = super::ArticleRow;
|
||||
type ParentType = gtk4::Box;
|
||||
|
||||
fn class_init(klass: &mut Self::Class) {
|
||||
klass.bind_template();
|
||||
}
|
||||
|
||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for ArticleRow {}
|
||||
impl WidgetImpl for ArticleRow {}
|
||||
impl BoxImpl for ArticleRow {}
|
||||
|
||||
impl ArticleRow {
|
||||
pub fn bind(&self, obj: &ArticleObject) {
|
||||
let article = obj.article();
|
||||
|
||||
self.feed_title_label.set_text(&article.feed_title);
|
||||
self.title_label.set_text(&article.title);
|
||||
self.excerpt_label.set_text(&article.excerpt);
|
||||
self.date_label.set_text(&relative_time(article.published));
|
||||
|
||||
self.update_read_style(article.unread);
|
||||
drop(article);
|
||||
|
||||
// Watch for unread state changes
|
||||
let title_label = self.title_label.clone();
|
||||
let id = obj.connect_notify_local(Some("unread"), move |obj, _| {
|
||||
let unread = obj.article().unread;
|
||||
if unread {
|
||||
title_label.remove_css_class("dim-label");
|
||||
} else {
|
||||
title_label.add_css_class("dim-label");
|
||||
}
|
||||
});
|
||||
*self.unread_handler.borrow_mut() = Some((obj.clone(), id));
|
||||
}
|
||||
|
||||
pub fn unbind(&self) {
|
||||
if let Some((obj, id)) = self.unread_handler.borrow_mut().take() {
|
||||
obj.disconnect(id);
|
||||
}
|
||||
for b in self.bindings.borrow_mut().drain(..) {
|
||||
b.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_read_style(&self, unread: bool) {
|
||||
if unread {
|
||||
self.title_label.remove_css_class("dim-label");
|
||||
} else {
|
||||
self.title_label.add_css_class("dim-label");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_time(unix: i64) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let diff = now - unix;
|
||||
if diff < 60 {
|
||||
"just now".to_string()
|
||||
} else if diff < 3600 {
|
||||
let m = diff / 60;
|
||||
format!("{m}m ago")
|
||||
} else if diff < 86400 {
|
||||
let h = diff / 3600;
|
||||
format!("{h}h ago")
|
||||
} else if diff < 172800 {
|
||||
"Yesterday".to_string()
|
||||
} else {
|
||||
let d = diff / 86400;
|
||||
format!("{d}d ago")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue