@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project. |
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
||||
# |
||||
# This file should be version controlled and should not be manually edited. |
||||
|
||||
version: |
||||
revision: "ead455963c12b453cdb2358cad34969c76daf180" |
||||
channel: "stable" |
||||
|
||||
project_type: package |
@ -0,0 +1,5 @@
|
||||
include: package:neon_lints/flutter.yaml |
||||
|
||||
analyzer: |
||||
exclude: |
||||
- lib/l10n/** |
@ -0,0 +1,6 @@
|
||||
arb-dir: lib/l10n |
||||
template-arb-file: en.arb |
||||
output-localization-file: localizations.dart |
||||
synthetic-package: false |
||||
output-dir: lib/l10n |
||||
nullable-getter: false |
@ -0,0 +1,4 @@
|
||||
{ |
||||
"@@locale": "en", |
||||
"noEntries": "No entries" |
||||
} |
@ -0,0 +1,125 @@
|
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/widgets.dart'; |
||||
import 'package:flutter_localizations/flutter_localizations.dart'; |
||||
import 'package:intl/intl.dart' as intl; |
||||
|
||||
import 'localizations_en.dart'; |
||||
|
||||
/// Callers can lookup localized strings with an instance of AppLocalizations |
||||
/// returned by `AppLocalizations.of(context)`. |
||||
/// |
||||
/// Applications need to include `AppLocalizations.delegate()` in their app's |
||||
/// `localizationDelegates` list, and the locales they support in the app's |
||||
/// `supportedLocales` list. For example: |
||||
/// |
||||
/// ```dart |
||||
/// import 'l10n/localizations.dart'; |
||||
/// |
||||
/// return MaterialApp( |
||||
/// localizationsDelegates: AppLocalizations.localizationsDelegates, |
||||
/// supportedLocales: AppLocalizations.supportedLocales, |
||||
/// home: MyApplicationHome(), |
||||
/// ); |
||||
/// ``` |
||||
/// |
||||
/// ## Update pubspec.yaml |
||||
/// |
||||
/// Please make sure to update your pubspec.yaml to include the following |
||||
/// packages: |
||||
/// |
||||
/// ```yaml |
||||
/// dependencies: |
||||
/// # Internationalization support. |
||||
/// flutter_localizations: |
||||
/// sdk: flutter |
||||
/// intl: any # Use the pinned version from flutter_localizations |
||||
/// |
||||
/// # Rest of dependencies |
||||
/// ``` |
||||
/// |
||||
/// ## iOS Applications |
||||
/// |
||||
/// iOS applications define key application metadata, including supported |
||||
/// locales, in an Info.plist file that is built into the application bundle. |
||||
/// To configure the locales supported by your app, you’ll need to edit this |
||||
/// file. |
||||
/// |
||||
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. |
||||
/// Then, in the Project Navigator, open the Info.plist file under the Runner |
||||
/// project’s Runner folder. |
||||
/// |
||||
/// Next, select the Information Property List item, select Add Item from the |
||||
/// Editor menu, then select Localizations from the pop-up menu. |
||||
/// |
||||
/// Select and expand the newly-created Localizations item then, for each |
||||
/// locale your application supports, add a new item and select the locale |
||||
/// you wish to add from the pop-up menu in the Value field. This list should |
||||
/// be consistent with the languages listed in the AppLocalizations.supportedLocales |
||||
/// property. |
||||
abstract class AppLocalizations { |
||||
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); |
||||
|
||||
final String localeName; |
||||
|
||||
static AppLocalizations of(BuildContext context) { |
||||
return Localizations.of<AppLocalizations>(context, AppLocalizations)!; |
||||
} |
||||
|
||||
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate(); |
||||
|
||||
/// A list of this localizations delegate along with the default localizations |
||||
/// delegates. |
||||
/// |
||||
/// Returns a list of localizations delegates containing this delegate along with |
||||
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, |
||||
/// and GlobalWidgetsLocalizations.delegate. |
||||
/// |
||||
/// Additional delegates can be added by appending to this list in |
||||
/// MaterialApp. This list does not have to be used at all if a custom list |
||||
/// of delegates is preferred or required. |
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[ |
||||
delegate, |
||||
GlobalMaterialLocalizations.delegate, |
||||
GlobalCupertinoLocalizations.delegate, |
||||
GlobalWidgetsLocalizations.delegate, |
||||
]; |
||||
|
||||
/// A list of this localizations delegate's supported locales. |
||||
static const List<Locale> supportedLocales = <Locale>[Locale('en')]; |
||||
|
||||
/// No description provided for @noEntries. |
||||
/// |
||||
/// In en, this message translates to: |
||||
/// **'No entries'** |
||||
String get noEntries; |
||||
} |
||||
|
||||
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> { |
||||
const _AppLocalizationsDelegate(); |
||||
|
||||
@override |
||||
Future<AppLocalizations> load(Locale locale) { |
||||
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale)); |
||||
} |
||||
|
||||
@override |
||||
bool isSupported(Locale locale) => <String>['en'].contains(locale.languageCode); |
||||
|
||||
@override |
||||
bool shouldReload(_AppLocalizationsDelegate old) => false; |
||||
} |
||||
|
||||
AppLocalizations lookupAppLocalizations(Locale locale) { |
||||
// Lookup logic when only language code is specified. |
||||
switch (locale.languageCode) { |
||||
case 'en': |
||||
return AppLocalizationsEn(); |
||||
} |
||||
|
||||
throw FlutterError('AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' |
||||
'an issue with the localizations generation tool. Please file an issue ' |
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration ' |
||||
'that was used.'); |
||||
} |
@ -0,0 +1,9 @@
|
||||
import 'localizations.dart'; |
||||
|
||||
/// The translations for English (`en`). |
||||
class AppLocalizationsEn extends AppLocalizations { |
||||
AppLocalizationsEn([String locale = 'en']) : super(locale); |
||||
|
||||
@override |
||||
String get noEntries => 'No entries'; |
||||
} |
@ -0,0 +1 @@
|
||||
export 'src/app.dart'; |
@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:go_router/go_router.dart'; |
||||
import 'package:neon/models.dart'; |
||||
import 'package:neon_dashboard/l10n/localizations.dart'; |
||||
import 'package:neon_dashboard/src/blocs/dashboard.dart'; |
||||
import 'package:neon_dashboard/src/options.dart'; |
||||
import 'package:neon_dashboard/src/pages/main.dart'; |
||||
import 'package:neon_dashboard/src/routes.dart'; |
||||
import 'package:nextcloud/core.dart' as core; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
|
||||
/// Implementation of the server `dashboard` app. |
||||
class DashboardApp extends AppImplementation<DashboardBloc, DashboardAppSpecificOptions> { |
||||
/// Creates a new Dashboard app implementation instance. |
||||
DashboardApp(); |
||||
|
||||
@override |
||||
final String id = AppIDs.dashboard; |
||||
|
||||
@override |
||||
final LocalizationsDelegate<AppLocalizations> localizationsDelegate = AppLocalizations.delegate; |
||||
|
||||
@override |
||||
final List<Locale> supportedLocales = AppLocalizations.supportedLocales; |
||||
|
||||
@override |
||||
late final DashboardAppSpecificOptions options = DashboardAppSpecificOptions(storage); |
||||
|
||||
@override |
||||
DashboardBloc buildBloc(final Account account) => DashboardBloc(account); |
||||
|
||||
@override |
||||
final Widget page = const DashboardMainPage(); |
||||
|
||||
@override |
||||
final RouteBase route = $dashboardAppRoute; |
||||
|
||||
@override |
||||
(bool?, String?) isSupported( |
||||
final Account account, |
||||
final core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data capabilities, |
||||
) => |
||||
const (null, null); |
||||
} |
@ -0,0 +1,103 @@
|
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:neon/blocs.dart'; |
||||
import 'package:neon/models.dart'; |
||||
import 'package:neon_dashboard/src/utils/find.dart'; |
||||
import 'package:nextcloud/dashboard.dart' as dashboard; |
||||
import 'package:rxdart/rxdart.dart'; |
||||
|
||||
/// Events for [DashboardBloc]. |
||||
abstract class DashboardBlocEvents {} |
||||
|
||||
/// States for [DashboardBloc]. |
||||
abstract class DashboardBlocStates { |
||||
/// Dashboard widgets that are displayed. |
||||
BehaviorSubject<Result<Map<dashboard.Widget, dashboard.WidgetItems?>>> get widgets; |
||||
} |
||||
|
||||
/// Implements the business logic for fetching dashboard widgets and their items. |
||||
class DashboardBloc extends InteractiveBloc implements DashboardBlocEvents, DashboardBlocStates { |
||||
/// Creates a new Dashboard Bloc instance. |
||||
/// |
||||
/// Automatically starts fetching the widgets and their items and refreshes everything every 30 seconds. |
||||
DashboardBloc(this._account) { |
||||
unawaited(refresh()); |
||||
|
||||
_timer = TimerBloc().registerTimer(const Duration(seconds: 30), refresh); |
||||
} |
||||
|
||||
final Account _account; |
||||
late final NeonTimer _timer; |
||||
|
||||
@override |
||||
BehaviorSubject<Result<Map<dashboard.Widget, dashboard.WidgetItems?>>> widgets = BehaviorSubject(); |
||||
|
||||
@override |
||||
void dispose() { |
||||
_timer.cancel(); |
||||
unawaited(widgets.close()); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Future<void> refresh() async { |
||||
widgets.add(widgets.valueOrNull?.asLoading() ?? Result.loading()); |
||||
|
||||
try { |
||||
final widgets = <String, dashboard.WidgetItems?>{}; |
||||
final v1WidgetIDs = <String>[]; |
||||
final v2WidgetIDs = <String>[]; |
||||
|
||||
final response = await _account.client.dashboard.dashboardApi.getWidgets(); |
||||
|
||||
for (final widget in response.body.ocs.data.values) { |
||||
if (widget.itemApiVersions.contains(2)) { |
||||
v2WidgetIDs.add(widget.id); |
||||
} else if (widget.itemApiVersions.contains(1)) { |
||||
v1WidgetIDs.add(widget.id); |
||||
} else { |
||||
debugPrint('Widget supports none of the API versions: ${widget.id}'); |
||||
} |
||||
} |
||||
|
||||
if (v1WidgetIDs.isNotEmpty) { |
||||
debugPrint('Loading v1 widgets: ${v1WidgetIDs.join(', ')}'); |
||||
|
||||
final response = await _account.client.dashboard.dashboardApi.getWidgetItems(widgets: v1WidgetIDs); |
||||
for (final entry in response.body.ocs.data.entries) { |
||||
widgets[entry.key] = dashboard.WidgetItems( |
||||
(final b) => b |
||||
..items.replace(entry.value) |
||||
..emptyContentMessage = '' |
||||
..halfEmptyContentMessage = '', |
||||
); |
||||
} |
||||
} |
||||
|
||||
if (v2WidgetIDs.isNotEmpty) { |
||||
debugPrint('Loading v2 widgets: ${v2WidgetIDs.join(', ')}'); |
||||
|
||||
final response = await _account.client.dashboard.dashboardApi.getWidgetItemsV2(widgets: v2WidgetIDs); |
||||
widgets.addEntries(response.body.ocs.data.entries); |
||||
} |
||||
|
||||
this.widgets.add( |
||||
Result.success( |
||||
widgets.map( |
||||
(final id, final items) => MapEntry( |
||||
response.body.ocs.data.values.find(id), |
||||
items, |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} catch (e, s) { |
||||
debugPrint(e.toString()); |
||||
debugPrint(s.toString()); |
||||
|
||||
widgets.add(Result.error(e)); |
||||
return; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,10 @@
|
||||
import 'package:neon/settings.dart'; |
||||
|
||||
/// Settings options specific to the dashboard app. |
||||
class DashboardAppSpecificOptions extends NextcloudAppOptions { |
||||
/// Creates a new dashboard options instance. |
||||
DashboardAppSpecificOptions(super.storage) { |
||||
super.categories = []; |
||||
super.options = []; |
||||
} |
||||
} |
@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:neon/blocs.dart'; |
||||
import 'package:neon/utils.dart'; |
||||
import 'package:neon/widgets.dart'; |
||||
import 'package:neon_dashboard/src/blocs/dashboard.dart'; |
||||
import 'package:neon_dashboard/src/widgets/widget.dart'; |
||||
|
||||
/// Displays the whole dashboard page layout. |
||||
class DashboardMainPage extends StatelessWidget { |
||||
/// Creates a new dashboard main page. |
||||
const DashboardMainPage({ |
||||
super.key, |
||||
}); |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
final bloc = NeonProvider.of<DashboardBloc>(context); |
||||
|
||||
return ResultBuilder.behaviorSubject( |
||||
stream: bloc.widgets, |
||||
builder: (final context, final snapshot) { |
||||
Widget? child; |
||||
if (snapshot.hasData) { |
||||
child = Wrap( |
||||
alignment: WrapAlignment.center, |
||||
spacing: 8, |
||||
runSpacing: 8, |
||||
children: snapshot.requireData.entries |
||||
.map( |
||||
(final widget) => DashboardWidget( |
||||
widget: widget.key, |
||||
items: widget.value, |
||||
), |
||||
) |
||||
.toList(), |
||||
); |
||||
} |
||||
|
||||
return Center( |
||||
child: NeonListView.custom( |
||||
isLoading: snapshot.isLoading, |
||||
error: snapshot.error, |
||||
onRefresh: bloc.refresh, |
||||
sliver: SliverFillRemaining( |
||||
hasScrollBody: false, |
||||
child: Center( |
||||
child: child, |
||||
), |
||||
), |
||||
), |
||||
); |
||||
}, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,21 @@
|
||||
import 'package:flutter/widgets.dart'; |
||||
import 'package:go_router/go_router.dart'; |
||||
import 'package:neon/utils.dart'; |
||||
import 'package:neon_dashboard/src/pages/main.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
|
||||
part 'routes.g.dart'; |
||||
|
||||
/// Route for the dashboard app. |
||||
@TypedGoRoute<DashboardAppRoute>( |
||||
path: '$appsBaseRoutePrefix${AppIDs.dashboard}', |
||||
name: AppIDs.dashboard, |
||||
) |
||||
@immutable |
||||
class DashboardAppRoute extends NeonBaseAppRoute { |
||||
/// Creates a new dashboard app route. |
||||
const DashboardAppRoute(); |
||||
|
||||
@override |
||||
Widget build(final BuildContext context, final GoRouterState state) => const DashboardMainPage(); |
||||
} |
@ -0,0 +1,33 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||
|
||||
part of 'routes.dart'; |
||||
|
||||
// ************************************************************************** |
||||
// GoRouterGenerator |
||||
// ************************************************************************** |
||||
|
||||
List<RouteBase> get $appRoutes => [ |
||||
$dashboardAppRoute, |
||||
]; |
||||
|
||||
RouteBase get $dashboardAppRoute => GoRouteData.$route( |
||||
path: '/apps/dashboard', |
||||
name: 'dashboard', |
||||
factory: $DashboardAppRouteExtension._fromState, |
||||
); |
||||
|
||||
extension $DashboardAppRouteExtension on DashboardAppRoute { |
||||
static DashboardAppRoute _fromState(GoRouterState state) => const DashboardAppRoute(); |
||||
|
||||
String get location => GoRouteData.$location( |
||||
'/apps/dashboard', |
||||
); |
||||
|
||||
void go(BuildContext context) => context.go(location); |
||||
|
||||
Future<T?> push<T>(BuildContext context) => context.push<T>(location); |
||||
|
||||
void pushReplacement(BuildContext context) => context.pushReplacement(location); |
||||
|
||||
void replace(BuildContext context) => context.replace(location); |
||||
} |
@ -0,0 +1,15 @@
|
||||
import 'package:collection/collection.dart'; |
||||
import 'package:nextcloud/dashboard.dart' as dashboard; |
||||
|
||||
/// Extension to find [dashboard.Widget]s. |
||||
extension WidgetFind on Iterable<dashboard.Widget> { |
||||
/// Finds the first widget that has the id set to [id]. |
||||
/// |
||||
/// Returns `null` if no matching widget was found. |
||||
dashboard.Widget? tryFind(final String id) => firstWhereOrNull((final widget) => widget.id == id); |
||||
|
||||
/// Finds the first widget that has the id set to [id]. |
||||
/// |
||||
/// Throws an exception if no matching widget was found. |
||||
dashboard.Widget find(final String id) => firstWhere((final widget) => widget.id == id); |
||||
} |
@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:go_router/go_router.dart'; |
||||
import 'package:neon/theme.dart'; |
||||
import 'package:neon/widgets.dart'; |
||||
import 'package:neon_dashboard/l10n/localizations.dart'; |
||||
import 'package:neon_dashboard/src/widgets/widget_button.dart'; |
||||
import 'package:neon_dashboard/src/widgets/widget_item.dart'; |
||||
import 'package:nextcloud/dashboard.dart' as dashboard; |
||||
|
||||
/// Displays a single dashboard widget and its items. |
||||
class DashboardWidget extends StatelessWidget { |
||||
/// Creates a new dashboard widget items. |
||||
const DashboardWidget({ |
||||
required this.widget, |
||||
required this.items, |
||||
super.key, |
||||
}); |
||||
|
||||
/// The dashboard widget to be displayed. |
||||
final dashboard.Widget widget; |
||||
|
||||
/// The items of the widget to be displayed. |
||||
final dashboard.WidgetItems? items; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
final halfEmptyContentMessage = _renderMessage(items?.halfEmptyContentMessage); |
||||
final emptyContentMessage = _renderMessage(items?.emptyContentMessage); |
||||
|
||||
return SizedBox( |
||||
width: 320, |
||||
height: 560, |
||||
child: Card( |
||||
child: InkWell( |
||||
onTap: widget.widgetUrl != null && widget.widgetUrl!.isNotEmpty ? () => context.go(widget.widgetUrl!) : null, |
||||
borderRadius: const BorderRadius.all(Radius.circular(12)), |
||||
child: ListView( |
||||
padding: const EdgeInsets.all(8), |
||||
children: [ |
||||
ListTile( |
||||
title: Text( |
||||
widget.title, |
||||
style: const TextStyle( |
||||
fontWeight: FontWeight.bold, |
||||
), |
||||
), |
||||
leading: SizedBox.square( |
||||
dimension: largeIconSize, |
||||
child: NeonUrlImage( |
||||
url: widget.iconUrl, |
||||
svgColorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), |
||||
size: const Size.square(largeIconSize), |
||||
), |
||||
), |
||||
), |
||||
const SizedBox( |
||||
height: 20, |
||||
), |
||||
if (halfEmptyContentMessage != null) halfEmptyContentMessage, |
||||
if (emptyContentMessage != null) emptyContentMessage, |
||||
if (halfEmptyContentMessage == null && emptyContentMessage == null && (items?.items.isEmpty ?? true)) |
||||
_renderMessage(AppLocalizations.of(context).noEntries)!, |
||||
...?items?.items.map( |
||||
(final item) => DashboardWidgetItem( |
||||
item: item, |
||||
roundIcon: widget.itemIconsRound, |
||||
), |
||||
), |
||||
const SizedBox( |
||||
height: 20, |
||||
), |
||||
...?widget.buttons?.map( |
||||
(final button) => DashboardWidgetButton( |
||||
button: button, |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
Widget? _renderMessage(final String? message) { |
||||
if (message == null || message.isEmpty) { |
||||
return null; |
||||
} |
||||
|
||||
return Center( |
||||
child: Column( |
||||
children: [ |
||||
const Icon( |
||||
Icons.check, |
||||
size: largeIconSize, |
||||
), |
||||
Text(message), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:go_router/go_router.dart'; |
||||
import 'package:nextcloud/dashboard.dart' as dashboard; |
||||
|
||||
/// Button inside a dashboard widget that is used to trigger an action. |
||||
class DashboardWidgetButton extends StatelessWidget { |
||||
/// Creates a new dashboard widget button. |
||||
const DashboardWidgetButton({ |
||||
required this.button, |
||||
super.key, |
||||
}); |
||||
|
||||
/// The dashboard widget button to be displayed. |
||||
final dashboard.Widget_Buttons button; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
void onPressed() => context.go(button.link); |
||||
final label = Text(button.text); |
||||
final icon = switch (button.type) { |
||||
'new' => Icons.add, |
||||
'more' => Icons.more_outlined, |
||||
'setup' => Icons.launch, |
||||
_ => null, |
||||
}; |
||||
if (icon != null) { |
||||
return Align( |
||||
child: FilledButton.icon( |
||||
onPressed: onPressed, |
||||
icon: Icon(icon), |
||||
label: label, |
||||
), |
||||
); |
||||
} |
||||
|
||||
return Align( |
||||
child: FilledButton( |
||||
onPressed: onPressed, |
||||
child: label, |
||||
), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:go_router/go_router.dart'; |
||||
import 'package:neon/theme.dart'; |
||||
import 'package:neon/widgets.dart'; |
||||
import 'package:nextcloud/dashboard.dart' as dashboard; |
||||
|
||||
/// A single item in the dashboard widget. |
||||
class DashboardWidgetItem extends StatelessWidget { |
||||
/// Creates a new dashboard widget item. |
||||
const DashboardWidgetItem({ |
||||
required this.item, |
||||
required this.roundIcon, |
||||
super.key, |
||||
}); |
||||
|
||||
/// The dashboard widget item to be displayed. |
||||
final dashboard.WidgetItem item; |
||||
|
||||
/// Whether the leading icon should have round corners. |
||||
final bool roundIcon; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
Widget leading = SizedBox.square( |
||||
dimension: largeIconSize, |
||||
child: NeonImageWrapper( |
||||
borderRadius: roundIcon ? BorderRadius.circular(largeIconSize) : null, |
||||
child: NeonUrlImage( |
||||
url: item.iconUrl, |
||||
size: const Size.square(largeIconSize), |
||||
), |
||||
), |
||||
); |
||||
if (item.overlayIconUrl.isNotEmpty) { |
||||
leading = Stack( |
||||
children: [ |
||||
leading, |
||||
SizedBox.square( |
||||
dimension: largeIconSize, |
||||
child: Align( |
||||
alignment: Alignment.bottomRight, |
||||
child: SizedBox.square( |
||||
dimension: smallIconSize, |
||||
child: NeonUrlImage( |
||||
url: item.overlayIconUrl, |
||||
size: const Size.square(smallIconSize), |
||||
), |
||||
), |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
return ListTile( |
||||
title: Text( |
||||
item.title, |
||||
overflow: TextOverflow.ellipsis, |
||||
), |
||||
subtitle: Text( |
||||
item.subtitle, |
||||
overflow: TextOverflow.ellipsis, |
||||
), |
||||
leading: leading, |
||||
onTap: item.link.isNotEmpty ? () => context.go(item.link) : null, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,43 @@
|
||||
name: neon_dashboard |
||||
version: 1.0.0 |
||||
publish_to: 'none' |
||||
|
||||
environment: |
||||
sdk: '>=3.1.0 <4.0.0' |
||||
flutter: '>=3.13.0' |
||||
|
||||
dependencies: |
||||
collection: ^1.0.0 |
||||
flutter: |
||||
sdk: flutter |
||||
flutter_localizations: |
||||
sdk: flutter |
||||
go_router: ^12.0.0 |
||||
neon: |
||||
git: |
||||
url: https://github.com/nextcloud/neon |
||||
path: packages/neon/neon |
||||
nextcloud: |
||||
git: |
||||
url: https://github.com/nextcloud/neon |
||||
path: packages/nextcloud |
||||
rxdart: ^0.27.0 |
||||
|
||||
dev_dependencies: |
||||
build_runner: ^2.4.6 |
||||
built_collection: ^5.1.1 |
||||
flutter_test: |
||||
sdk: flutter |
||||
go_router_builder: ^2.3.3 |
||||
mocktail: ^1.0.1 |
||||
neon_lints: |
||||
git: |
||||
url: https://github.com/nextcloud/neon |
||||
path: packages/neon_lints |
||||
test: ^1.24.3 |
||||
vector_graphics_compiler: ^1.1.9 |
||||
|
||||
flutter: |
||||
uses-material-design: true |
||||
assets: |
||||
- assets/ |
@ -0,0 +1,12 @@
|
||||
# melos_managed_dependency_overrides: dynamite_runtime,neon,neon_lints,nextcloud,sort_box |
||||
dependency_overrides: |
||||
dynamite_runtime: |
||||
path: ../../dynamite/dynamite_runtime |
||||
neon: |
||||
path: ../neon |
||||
neon_lints: |
||||
path: ../../neon_lints |
||||
nextcloud: |
||||
path: ../../nextcloud |
||||
sort_box: |
||||
path: ../../sort_box |
@ -0,0 +1,30 @@
|
||||
import 'package:mocktail/mocktail.dart'; |
||||
import 'package:neon_dashboard/src/utils/find.dart'; |
||||
import 'package:nextcloud/dashboard.dart' as dashboard; |
||||
import 'package:test/test.dart'; |
||||
|
||||
// ignore: missing_override_of_must_be_overridden, avoid_implementing_value_types |
||||
class DashboardWidgetMock extends Mock implements dashboard.Widget {} |
||||
|
||||
void main() { |
||||
group('group name', () { |
||||
test('AccountFind', () { |
||||
final widget1 = DashboardWidgetMock(); |
||||
final widget2 = DashboardWidgetMock(); |
||||
|
||||
final widgets = { |
||||
widget1, |
||||
widget2, |
||||
}; |
||||
|
||||
when(() => widget1.id).thenReturn('widget1'); |
||||
when(() => widget2.id).thenReturn('widget2'); |
||||
|
||||
expect(widgets.tryFind('invalidID'), isNull); |
||||
expect(widgets.tryFind(widget2.id), equals(widget2)); |
||||
|
||||
expect(() => widgets.find('invalidID'), throwsA(isA<StateError>())); |
||||
expect(widgets.find(widget2.id), equals(widget2)); |
||||
}); |
||||
}); |
||||
} |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 4.7 KiB |
@ -0,0 +1,407 @@
|
||||
import 'package:built_collection/built_collection.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_test/flutter_test.dart'; |
||||
import 'package:mocktail/mocktail.dart'; |
||||
import 'package:neon/blocs.dart'; |
||||
import 'package:neon/models.dart'; |
||||
import 'package:neon/theme.dart'; |
||||
import 'package:neon/utils.dart'; |
||||
import 'package:neon/widgets.dart'; |
||||
import 'package:neon_dashboard/l10n/localizations.dart'; |
||||
import 'package:neon_dashboard/src/widgets/widget.dart'; |
||||
import 'package:neon_dashboard/src/widgets/widget_button.dart'; |
||||
import 'package:neon_dashboard/src/widgets/widget_item.dart'; |
||||
import 'package:nextcloud/dashboard.dart' as dashboard; |
||||
import 'package:rxdart/rxdart.dart'; |
||||
|
||||
class MockAccountsBloc extends Mock implements AccountsBloc {} |
||||
|
||||
Widget wrapWidget(final AccountsBloc accountsBloc, final Widget child) => MaterialApp( |
||||
localizationsDelegates: AppLocalizations.localizationsDelegates, |
||||
supportedLocales: AppLocalizations.supportedLocales, |
||||
home: Scaffold( |
||||
backgroundColor: Colors.transparent, |
||||
body: NeonProvider<AccountsBloc>.value( |
||||
value: accountsBloc, |
||||
child: child, |
||||
), |
||||
), |
||||
); |
||||
|
||||
void main() { |
||||
final accountsBloc = MockAccountsBloc(); |
||||
when(() => accountsBloc.activeAccount).thenAnswer( |
||||
(final invocation) => BehaviorSubject.seeded( |
||||
Account( |
||||
serverURL: Uri(), |
||||
username: 'example', |
||||
), |
||||
), |
||||
); |
||||
|
||||
group('Widget item', () { |
||||
final item = dashboard.WidgetItem( |
||||
(final b) => b |
||||
..title = 'Widget item title' |
||||
..subtitle = 'Widget item subtitle' |
||||
..link = 'https://example.com/link' |
||||
..iconUrl = 'https://example.com/iconUrl' |
||||
..overlayIconUrl = 'https://example.com/overlayIconUrl' |
||||
..sinceId = '', |
||||
); |
||||
|
||||
testWidgets('Everything filled', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidgetItem( |
||||
item: item, |
||||
roundIcon: true, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.text('Widget item title'), findsOneWidget); |
||||
expect(find.text('Widget item subtitle'), findsOneWidget); |
||||
expect(find.byType(InkWell), findsOneWidget); |
||||
expect( |
||||
tester.widget(find.byType(InkWell)), |
||||
isA<InkWell>().having( |
||||
(final a) => a.onTap, |
||||
'onTap is not null', |
||||
isNotNull, |
||||
), |
||||
); |
||||
expect(find.byType(NeonImageWrapper), findsOneWidget); |
||||
expect( |
||||
tester.widget(find.byType(NeonImageWrapper)), |
||||
isA<NeonImageWrapper>().having( |
||||
(final a) => a.borderRadius, |
||||
'borderRadius is correct', |
||||
BorderRadius.circular(largeIconSize), |
||||
), |
||||
); |
||||
expect(find.byType(NeonCachedImage), findsNWidgets(2)); |
||||
|
||||
expect(find.byType(DashboardWidgetItem), matchesGoldenFile('goldens/widget_item.png')); |
||||
}); |
||||
|
||||
testWidgets('Not round', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidgetItem( |
||||
item: item, |
||||
roundIcon: false, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect( |
||||
tester.widget(find.byType(NeonImageWrapper)), |
||||
isA<NeonImageWrapper>().having( |
||||
(final a) => a.borderRadius, |
||||
'borderRadius is null', |
||||
null, |
||||
), |
||||
); |
||||
|
||||
expect(find.byType(DashboardWidgetItem), matchesGoldenFile('goldens/widget_item_not_round.png')); |
||||
}); |
||||
|
||||
testWidgets('Without link', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidgetItem( |
||||
item: item.rebuild((final b) => b..link = ''), |
||||
roundIcon: true, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect( |
||||
tester.widget(find.byType(InkWell)), |
||||
isA<InkWell>().having( |
||||
(final a) => a.onTap, |
||||
'onTap is null', |
||||
isNull, |
||||
), |
||||
); |
||||
}); |
||||
|
||||
testWidgets('Without overlayIconUrl', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidgetItem( |
||||
item: item.rebuild((final b) => b..overlayIconUrl = ''), |
||||
roundIcon: true, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.byType(NeonCachedImage), findsOneWidget); |
||||
}); |
||||
}); |
||||
|
||||
group('Widget button', () { |
||||
final button = dashboard.Widget_Buttons( |
||||
(final b) => b |
||||
..type = 'new' |
||||
..text = 'Button' |
||||
..link = 'https://example.com/link', |
||||
); |
||||
|
||||
testWidgets('New', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidgetButton( |
||||
button: button, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.byIcon(Icons.add), findsOneWidget); |
||||
expect(find.text('Button'), findsOneWidget); |
||||
|
||||
expect(find.byType(DashboardWidgetButton), matchesGoldenFile('goldens/widget_button_new.png')); |
||||
}); |
||||
|
||||
testWidgets('More', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidgetButton( |
||||
button: button.rebuild((final b) => b.type = 'more'), |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.byIcon(Icons.more_outlined), findsOneWidget); |
||||
expect(find.text('Button'), findsOneWidget); |
||||
|
||||
expect(find.byType(DashboardWidgetButton), matchesGoldenFile('goldens/widget_button_more.png')); |
||||
}); |
||||
|
||||
testWidgets('Setup', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidgetButton( |
||||
button: button.rebuild((final b) => b.type = 'setup'), |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.byIcon(Icons.launch), findsOneWidget); |
||||
expect(find.text('Button'), findsOneWidget); |
||||
|
||||
expect(find.byType(DashboardWidgetButton), matchesGoldenFile('goldens/widget_button_setup.png')); |
||||
}); |
||||
|
||||
testWidgets('Invalid', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidgetButton( |
||||
button: button.rebuild((final b) => b.type = 'test'), |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.byType(Icon), findsNothing); |
||||
expect(find.text('Button'), findsOneWidget); |
||||
|
||||
expect(find.byType(DashboardWidgetButton), matchesGoldenFile('goldens/widget_button_invalid.png')); |
||||
}); |
||||
}); |
||||
|
||||
group('Widget', () { |
||||
final item = dashboard.WidgetItem( |
||||
(final b) => b |
||||
..title = 'Widget item title' |
||||
..subtitle = 'Widget item subtitle' |
||||
..link = 'https://example.com/link' |
||||
..iconUrl = 'https://example.com/iconUrl' |
||||
..overlayIconUrl = 'https://example.com/overlayIconUrl' |
||||
..sinceId = '', |
||||
); |
||||
final items = dashboard.WidgetItems( |
||||
(final b) => b |
||||
..items = BuiltList<dashboard.WidgetItem>.from([item]).toBuilder() |
||||
..emptyContentMessage = '' |
||||
..halfEmptyContentMessage = '', |
||||
); |
||||
final button = dashboard.Widget_Buttons( |
||||
(final b) => b |
||||
..type = 'new' |
||||
..text = 'Button' |
||||
..link = 'https://example.com/link', |
||||
); |
||||
final widget = dashboard.Widget( |
||||
(final b) => b |
||||
..id = 'id' |
||||
..title = 'Widget title' |
||||
..order = 0 |
||||
..iconClass = '' |
||||
..iconUrl = 'https://example.com/iconUrl' |
||||
..widgetUrl = 'https://example.com/widgetUrl' |
||||
..itemIconsRound = true |
||||
..itemApiVersions = BuiltList<int>.from([1, 2]).toBuilder() |
||||
..reloadInterval = 0 |
||||
..buttons = BuiltList<dashboard.Widget_Buttons>.from([button]).toBuilder(), |
||||
); |
||||
|
||||
testWidgets('Everything filled', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidget( |
||||
widget: widget, |
||||
items: items, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.text('Widget title'), findsOneWidget); |
||||
expect(find.byType(InkWell), findsNWidgets(4)); |
||||
expect( |
||||
tester.widget(find.byType(InkWell).first), |
||||
isA<InkWell>().having( |
||||
(final a) => a.onTap, |
||||
'onTap is not null', |
||||
isNotNull, |
||||
), |
||||
); |
||||
expect(find.byType(NeonImageWrapper), findsOneWidget); |
||||
expect( |
||||
tester.widget(find.byType(NeonImageWrapper)), |
||||
isA<NeonImageWrapper>().having( |
||||
(final a) => a.borderRadius, |
||||
'borderRadius is correct', |
||||
BorderRadius.circular(largeIconSize), |
||||
), |
||||
); |
||||
expect(find.byType(NeonCachedImage), findsNWidgets(3)); |
||||
expect(find.byType(DashboardWidgetItem), findsOneWidget); |
||||
expect(find.bySubtype<FilledButton>(), findsOneWidget); |
||||
expect(find.byIcon(Icons.add), findsOneWidget); |
||||
expect(find.text('Button'), findsOneWidget); |
||||
|
||||
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget.png')); |
||||
}); |
||||
|
||||
testWidgets('Without widgetUrl', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidget( |
||||
widget: widget.rebuild((final b) => b.widgetUrl = ''), |
||||
items: items, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect( |
||||
tester.widget(find.byType(InkWell).first), |
||||
isA<InkWell>().having( |
||||
(final a) => a.onTap, |
||||
'onTap is null', |
||||
isNull, |
||||
), |
||||
); |
||||
}); |
||||
|
||||
testWidgets('Not round', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidget( |
||||
widget: widget.rebuild((final b) => b.itemIconsRound = false), |
||||
items: items, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect( |
||||
tester.widget(find.byType(NeonImageWrapper)), |
||||
isA<NeonImageWrapper>().having( |
||||
(final a) => a.borderRadius, |
||||
'borderRadius is null', |
||||
null, |
||||
), |
||||
); |
||||
|
||||
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_not_round.png')); |
||||
}); |
||||
|
||||
testWidgets('With halfEmptyContentMessage', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidget( |
||||
widget: widget, |
||||
items: items.rebuild((final b) => b.halfEmptyContentMessage = 'Half empty'), |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.text('Half empty'), findsOneWidget); |
||||
expect(find.byIcon(Icons.check), findsOneWidget); |
||||
|
||||
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_with_half_empty.png')); |
||||
}); |
||||
|
||||
testWidgets('With emptyContentMessage', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidget( |
||||
widget: widget, |
||||
items: items.rebuild((final b) => b.emptyContentMessage = 'Empty'), |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.text('Empty'), findsOneWidget); |
||||
expect(find.byIcon(Icons.check), findsOneWidget); |
||||
|
||||
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_with_empty.png')); |
||||
}); |
||||
|
||||
testWidgets('Without items', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidget( |
||||
widget: widget, |
||||
items: null, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.text('No entries'), findsOneWidget); |
||||
expect(find.byIcon(Icons.check), findsOneWidget); |
||||
|
||||
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_without_items.png')); |
||||
}); |
||||
|
||||
testWidgets('Without buttons', (final tester) async { |
||||
await tester.pumpWidget( |
||||
wrapWidget( |
||||
accountsBloc, |
||||
DashboardWidget( |
||||
widget: widget.rebuild((final b) => b.buttons.clear()), |
||||
items: items, |
||||
), |
||||
), |
||||
); |
||||
|
||||
expect(find.bySubtype<FilledButton>(), findsNothing); |
||||
|
||||
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_without_buttons.png')); |
||||
}); |
||||
}); |
||||
} |