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:
Jeena 2026-03-20 11:57:06 +00:00
parent 8db0b16954
commit 813dda3579
22 changed files with 1838 additions and 42 deletions

76
src/model.rs Normal file
View file

@ -0,0 +1,76 @@
use gtk4::glib;
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use std::cell::RefCell;
#[derive(Debug, Clone, Default)]
pub struct Article {
pub id: String,
pub title: String,
pub feed_title: String,
pub author: String,
pub link: String,
pub published: i64,
pub content: String,
pub excerpt: String,
pub unread: bool,
}
// ── GObject wrapper ──────────────────────────────────────────────────────────
glib::wrapper! {
pub struct ArticleObject(ObjectSubclass<imp::ArticleObject>);
}
impl ArticleObject {
pub fn new(article: Article) -> Self {
let obj: Self = glib::Object::new();
*obj.imp().article.borrow_mut() = article;
obj
}
pub fn article(&self) -> std::cell::Ref<'_, Article> {
self.imp().article.borrow()
}
pub fn set_unread(&self, unread: bool) {
self.imp().article.borrow_mut().unread = unread;
self.notify("unread");
}
}
mod imp {
use super::*;
#[derive(Default)]
pub struct ArticleObject {
pub article: RefCell<Article>,
}
#[glib::object_subclass]
impl ObjectSubclass for ArticleObject {
const NAME: &'static str = "ArticleObject";
type Type = super::ArticleObject;
}
impl ObjectImpl for ArticleObject {
fn properties() -> &'static [glib::ParamSpec] {
use std::sync::OnceLock;
static PROPS: OnceLock<Vec<glib::ParamSpec>> = OnceLock::new();
PROPS.get_or_init(|| {
vec![
glib::ParamSpecBoolean::builder("unread")
.read_only()
.build(),
]
})
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"unread" => self.article.borrow().unread.to_value(),
_ => unimplemented!(),
}
}
}
}