worldhopper/lib/config/router.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

179 lines
6.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:worldhopper/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:worldhopper/config/constants.dart';
import 'package:worldhopper/screens/servers/server_list_screen.dart';
import 'package:worldhopper/screens/servers/add_server_screen.dart';
import 'package:worldhopper/screens/servers/edit_server_screen.dart';
import 'package:worldhopper/screens/browse/library_browser_screen.dart';
import 'package:worldhopper/screens/browse/feed_screen.dart';
import 'package:worldhopper/screens/publication/publication_detail_screen.dart';
import 'package:worldhopper/screens/reader/reader_screen.dart';
import 'package:worldhopper/screens/reader/epub_reader_screen.dart';
import 'package:worldhopper/screens/library/library_screen.dart';
import 'package:worldhopper/screens/library/downloaded_series_detail_screen.dart';
import 'package:worldhopper/screens/settings/settings_screen.dart';
import 'package:worldhopper/screens/shell/main_shell_screen.dart';
import 'package:worldhopper/models/entry.dart';
/// Application router configuration using GoRouter
class AppRouter {
AppRouter._();
static final GlobalKey<NavigatorState> _libraryNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: 'library');
static final GlobalKey<NavigatorState> _serversNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: 'servers');
static final GlobalKey<NavigatorState> _settingsNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: 'settings');
/// Navigator keys for each bottom navigation branch, indexed by tab position.
static final List<GlobalKey<NavigatorState>> branchNavigatorKeys = [
_libraryNavigatorKey,
_serversNavigatorKey,
_settingsNavigatorKey,
];
static final GoRouter router = GoRouter(
initialLocation: AppConstants.routeLibrary,
routes: [
// StatefulShellRoute for bottom navigation
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return MainShellScreen(navigationShell: navigationShell);
},
branches: [
// Branch 1: Library
StatefulShellBranch(
navigatorKey: _libraryNavigatorKey,
routes: [
GoRoute(
path: AppConstants.routeLibrary,
name: 'library',
builder: (context, state) => const LibraryScreen(),
),
],
),
// Branch 2: Servers (home)
StatefulShellBranch(
navigatorKey: _serversNavigatorKey,
routes: [
GoRoute(
path: AppConstants.routeServerList,
name: 'server-list',
builder: (context, state) => const ServerListScreen(),
routes: [
// Nested browse routes
GoRoute(
path: 'browse/:serverId',
name: 'browse-library',
builder: (context, state) {
final serverId = state.pathParameters['serverId']!;
return LibraryBrowserScreen(serverId: serverId);
},
routes: [
GoRoute(
path: 'feed',
name: 'browse-feed',
builder: (context, state) {
final serverId = state.pathParameters['serverId']!;
final extra = state.extra as Map<String, dynamic>;
return FeedScreen(
serverId: serverId,
feedUrl: extra['feedUrl'] as String,
feedTitle: extra['feedTitle'] as String,
);
},
),
],
),
],
),
],
),
// Branch 3: Settings
StatefulShellBranch(
navigatorKey: _settingsNavigatorKey,
routes: [
GoRoute(
path: AppConstants.routeSettings,
name: 'settings',
builder: (context, state) => const SettingsScreen(),
),
],
),
],
),
// Standalone routes (full-screen, no bottom navigation)
GoRoute(
path: AppConstants.routeAddServer,
name: 'add-server',
builder: (context, state) => const AddServerScreen(),
),
GoRoute(
path: '${AppConstants.routeEditServer}/:id',
name: 'edit-server',
builder: (context, state) {
final serverId = state.pathParameters['id']!;
return EditServerScreen(serverId: serverId);
},
),
GoRoute(
path: AppConstants.routePublicationDetail,
name: 'publication-detail',
builder: (context, state) {
final entry = state.extra as Map<String, dynamic>;
return PublicationDetailScreen(
entry: entry['entry'] as Entry,
serverId: entry['serverId'] as String,
feedUrl: entry['feedUrl'] as String?,
);
},
),
GoRoute(
path: AppConstants.routeReader,
name: 'reader',
builder: (context, state) {
final data = state.extra as Map<String, dynamic>;
return ReaderScreen(
entry: data['entry'] as Entry,
serverId: data['serverId'] as String,
initialPage: data['initialPage'] as int? ?? 0,
feedUrl: data['feedUrl'] as String?,
localContentPath: data['localContentPath'] as String?,
);
},
),
GoRoute(
path: AppConstants.routeEpubReader,
name: 'epub-reader',
builder: (context, state) {
final data = state.extra as Map<String, dynamic>;
return EpubReaderScreen(
entry: data['entry'] as Entry,
serverId: data['serverId'] as String,
feedUrl: data['feedUrl'] as String?,
localFilePath: data['localFilePath'] as String?,
);
},
),
GoRoute(
path: AppConstants.routeDownloadedSeriesDetail,
name: 'downloaded-series-detail',
builder: (context, state) {
final data = state.extra as Map<String, dynamic>;
return DownloadedSeriesDetailScreen(
seriesId: data['seriesId'] as String,
);
},
),
],
errorBuilder: (context, state) => Scaffold(
body: Center(
child: Text(
AppLocalizations.of(context).routeNotFound(state.uri.toString())),
),
),
);
}