Compare commits
12 Commits
fe2b3c43b2
...
15791423ea
| Author | SHA1 | Date | |
|---|---|---|---|
| 15791423ea | |||
| 2f5a6b4ce0 | |||
| 53bc6d5360 | |||
| f50359b4eb | |||
| 9994a1f3fa | |||
| 4aa31a2e44 | |||
| dfce3e7b5c | |||
| 564a334cdc | |||
| db329c7299 | |||
| 0a2ff5c3fb | |||
| 9b5198c6db | |||
| 94794ff092 |
@@ -1,14 +0,0 @@
|
|||||||
import '../../../../api_response.dart';
|
|
||||||
import '../../webdav_api.dart';
|
|
||||||
import 'download_file_params.dart';
|
|
||||||
|
|
||||||
class DownloadFile extends WebdavApi<DownloadFileParams> {
|
|
||||||
DownloadFileParams params;
|
|
||||||
|
|
||||||
DownloadFile(this.params) : super(params);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ApiResponse> run() async {
|
|
||||||
throw UnimplementedError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
|
|
||||||
import '../../../../api_params.dart';
|
|
||||||
|
|
||||||
part 'download_file_params.g.dart';
|
|
||||||
|
|
||||||
@JsonSerializable()
|
|
||||||
class DownloadFileParams extends ApiParams {
|
|
||||||
String webdavSourcePath;
|
|
||||||
String localTargetPath;
|
|
||||||
String filename;
|
|
||||||
|
|
||||||
DownloadFileParams(
|
|
||||||
this.webdavSourcePath,
|
|
||||||
this.localTargetPath,
|
|
||||||
this.filename,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory DownloadFileParams.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$DownloadFileParamsFromJson(json);
|
|
||||||
Map<String, dynamic> toJson() => _$DownloadFileParamsToJson(this);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'download_file_params.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
DownloadFileParams _$DownloadFileParamsFromJson(Map<String, dynamic> json) =>
|
|
||||||
DownloadFileParams(
|
|
||||||
json['webdavSourcePath'] as String,
|
|
||||||
json['localTargetPath'] as String,
|
|
||||||
json['filename'] as String,
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadFileParamsToJson(DownloadFileParams instance) =>
|
|
||||||
<String, dynamic>{
|
|
||||||
'webdavSourcePath': instance.webdavSourcePath,
|
|
||||||
'localTargetPath': instance.localTargetPath,
|
|
||||||
'filename': instance.filename,
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
|
|
||||||
part 'download_file_response.g.dart';
|
|
||||||
|
|
||||||
@JsonSerializable()
|
|
||||||
class DownloadFileResponse {
|
|
||||||
String path;
|
|
||||||
|
|
||||||
DownloadFileResponse(this.path);
|
|
||||||
|
|
||||||
factory DownloadFileResponse.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$DownloadFileResponseFromJson(json);
|
|
||||||
Map<String, dynamic> toJson() => _$DownloadFileResponseToJson(this);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'download_file_response.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
DownloadFileResponse _$DownloadFileResponseFromJson(
|
|
||||||
Map<String, dynamic> json,
|
|
||||||
) => DownloadFileResponse(json['path'] as String);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadFileResponseToJson(
|
|
||||||
DownloadFileResponse instance,
|
|
||||||
) => <String, dynamic>{'path': instance.path};
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'errors/marianumconnect_error.dart';
|
||||||
|
import 'marianumconnect_api.dart';
|
||||||
|
import 'marianumconnect_endpoint.dart';
|
||||||
|
|
||||||
|
/// Shared base for MarianumConnect API queries. Owns the [dio] client (the
|
||||||
|
/// shared authenticated singleton by default) and routes calls through [guard]
|
||||||
|
/// so every query maps a DioException to the app's typed AppExceptions the same
|
||||||
|
/// way instead of repeating the try/catch. Subclasses with bespoke error or
|
||||||
|
/// lifecycle handling (own dio, silent failure, custom status mapping) may skip
|
||||||
|
/// [guard] and still reuse [dio]/[endpoint].
|
||||||
|
abstract class MarianumConnectQuery {
|
||||||
|
final Dio dio;
|
||||||
|
|
||||||
|
MarianumConnectQuery({Dio? dio}) : dio = dio ?? MarianumConnectApi.dio();
|
||||||
|
|
||||||
|
/// Resolves [path] against the active mobile-API base URL.
|
||||||
|
String endpoint(String path) => MarianumConnectEndpoint.resolve(path);
|
||||||
|
|
||||||
|
/// Runs [body], converting any DioException into the matching AppException.
|
||||||
|
Future<T> guard<T>(Future<T> Function() body) async {
|
||||||
|
try {
|
||||||
|
return await body();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,27 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'auth_login_response.dart';
|
import 'auth_login_response.dart';
|
||||||
|
|
||||||
/// Performs the Marianum-Connect bearer login. Used both by the foreground
|
/// Performs the Marianum-Connect bearer login. Used both by the foreground
|
||||||
/// login flow and by the auth interceptor's silent re-auth on 401. Does *not*
|
/// login flow and by the auth interceptor's silent re-auth on 401. Does *not*
|
||||||
/// run through the shared dio instance — that one has the interceptor, which
|
/// run through the shared dio instance — that one has the interceptor, which
|
||||||
/// would attempt to re-auth us into a loop if our credentials are wrong.
|
/// would attempt to re-auth us into a loop if our credentials are wrong.
|
||||||
class AuthLogin {
|
class AuthLogin extends MarianumConnectQuery {
|
||||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||||
|
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
AuthLogin({
|
AuthLogin({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
Dio? dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage,
|
||||||
_dio =
|
super(dio: dio ?? _buildDio());
|
||||||
dio ??
|
|
||||||
Dio(
|
static Dio _buildDio() => Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
connectTimeout: _connectTimeout,
|
connectTimeout: _connectTimeout,
|
||||||
receiveTimeout: _receiveTimeout,
|
receiveTimeout: _receiveTimeout,
|
||||||
@@ -37,10 +35,9 @@ class AuthLogin {
|
|||||||
required String username,
|
required String username,
|
||||||
required String password,
|
required String password,
|
||||||
required String tokenName,
|
required String tokenName,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
endpoint('auth/login'),
|
||||||
MarianumConnectEndpoint.resolve('auth/login'),
|
|
||||||
data: {
|
data: {
|
||||||
'username': username,
|
'username': username,
|
||||||
'password': password,
|
'password': password,
|
||||||
@@ -54,8 +51,5 @@ class AuthLogin {
|
|||||||
expiresAt: payload.expiresAt,
|
expiresAt: payload.expiresAt,
|
||||||
);
|
);
|
||||||
return payload;
|
return payload;
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
||||||
/// — a network error still clears the local token so the user isn't stuck with
|
/// — a network error still clears the local token so the user isn't stuck with
|
||||||
/// an unusable session.
|
/// an unusable session.
|
||||||
class AuthLogout {
|
class AuthLogout extends MarianumConnectQuery {
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
AuthLogout({
|
AuthLogout({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
super.dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage;
|
||||||
_dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
try {
|
try {
|
||||||
await _dio.post<void>(MarianumConnectEndpoint.resolve('auth/logout'));
|
await dio.post<void>(endpoint('auth/logout'));
|
||||||
} on DioException catch (_) {
|
} on DioException catch (_) {
|
||||||
// ignore — local clear below still happens
|
// ignore — local clear below still happens
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../../../errors/auth_exception.dart';
|
import '../../../errors/auth_exception.dart';
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Probes that the stored bearer token still maps to the given credentials.
|
/// Probes that the stored bearer token still maps to the given credentials.
|
||||||
/// Server returns 200 only when the credentials belong to the user that the
|
/// Server returns 200 only when the credentials belong to the user that the
|
||||||
@@ -12,21 +11,20 @@ import '../../marianumconnect_endpoint.dart';
|
|||||||
///
|
///
|
||||||
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
||||||
/// and obscure a real 401 with a silent re-login.
|
/// and obscure a real 401 with a silent re-login.
|
||||||
class AuthVerify {
|
class AuthVerify extends MarianumConnectQuery {
|
||||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||||
|
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
AuthVerify({
|
AuthVerify({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
Dio? dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage,
|
||||||
_dio =
|
super(dio: dio ?? _buildDio());
|
||||||
dio ??
|
|
||||||
Dio(
|
static Dio _buildDio() => Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
connectTimeout: _connectTimeout,
|
connectTimeout: _connectTimeout,
|
||||||
sendTimeout: _connectTimeout,
|
sendTimeout: _connectTimeout,
|
||||||
@@ -49,14 +47,12 @@ class AuthVerify {
|
|||||||
technicalDetails: 'AuthVerify: no bearer token in storage',
|
technicalDetails: 'AuthVerify: no bearer token in storage',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
return guard(() async {
|
||||||
await _dio.post<void>(
|
await dio.post<void>(
|
||||||
MarianumConnectEndpoint.resolve('auth/verify'),
|
endpoint('auth/verify'),
|
||||||
data: {'username': username, 'password': password},
|
data: {'username': username, 'password': password},
|
||||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_breakers_response.dart';
|
import 'get_breakers_response.dart';
|
||||||
|
|
||||||
/// Fetches the app maintenance breaker rules from `GET /api/mobile/v1/breaker`.
|
/// Fetches the app maintenance breaker rules from `GET /api/mobile/v1/breaker`.
|
||||||
/// The endpoint is public: the bearer token is attached if present but not
|
/// The endpoint is public: the bearer token is attached if present but not
|
||||||
/// required, so this also works before login (e.g. to block the whole app).
|
/// required, so this also works before login (e.g. to block the whole app).
|
||||||
class GetBreakers {
|
class GetBreakers extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetBreakers({super.dio});
|
||||||
|
|
||||||
GetBreakers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<GetBreakersResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
|
||||||
Future<GetBreakersResponse> run() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('breaker'),
|
|
||||||
);
|
|
||||||
return GetBreakersResponse.fromJson(response.data!);
|
return GetBreakersResponse.fromJson(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_capabilities_response.dart';
|
import 'get_capabilities_response.dart';
|
||||||
|
|
||||||
/// Fetches the current user's mobile capability flags from
|
/// Fetches the current user's mobile capability flags from
|
||||||
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
|
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
|
||||||
/// so the bearer token is attached automatically.
|
/// so the bearer token is attached automatically.
|
||||||
class GetCapabilities {
|
class GetCapabilities extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetCapabilities({super.dio});
|
||||||
|
|
||||||
GetCapabilities({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<CapabilitiesResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
Future<CapabilitiesResponse> run() async {
|
endpoint('me/capabilities'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('me/capabilities'),
|
|
||||||
);
|
);
|
||||||
return CapabilitiesResponse.fromJson(response.data!);
|
return CapabilitiesResponse.fromJson(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import '../../models/mc_holiday.dart';
|
import '../../models/mc_holiday.dart';
|
||||||
|
|
||||||
class GetHolidays {
|
class GetHolidays extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetHolidays({super.dio});
|
||||||
|
|
||||||
GetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<List<McHoliday>> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(endpoint('holidays'));
|
||||||
Future<List<McHoliday>> run() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.get<List<dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('holidays'),
|
|
||||||
);
|
|
||||||
return response.data!
|
return response.data!
|
||||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Downloads the raw PDF bytes of a Marianum Message from
|
/// Downloads the raw PDF bytes of a Marianum Message from
|
||||||
/// `GET /api/mobile/v1/newsletter/{id}/file`.
|
/// `GET /api/mobile/v1/newsletter/{id}/file`.
|
||||||
@@ -12,23 +10,16 @@ import '../../marianumconnect_endpoint.dart';
|
|||||||
/// Goes through the shared MC dio so the bearer token is attached automatically;
|
/// Goes through the shared MC dio so the bearer token is attached automatically;
|
||||||
/// the bytes are handed to `SfPdfViewer.memory` so no auth header has to be
|
/// the bytes are handed to `SfPdfViewer.memory` so no auth header has to be
|
||||||
/// plumbed into the viewer itself.
|
/// plumbed into the viewer itself.
|
||||||
class GetNewsletterFile {
|
class GetNewsletterFile extends MarianumConnectQuery {
|
||||||
final String id;
|
final String id;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
GetNewsletterFile(this.id, {Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
GetNewsletterFile(this.id, {super.dio});
|
||||||
|
|
||||||
Future<Uint8List> run() async {
|
Future<Uint8List> run() => guard(() async {
|
||||||
try {
|
final response = await dio.get<List<int>>(
|
||||||
final response = await _dio.get<List<int>>(
|
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
|
||||||
MarianumConnectEndpoint.resolve(
|
|
||||||
'newsletter/${Uri.encodeComponent(id)}/file',
|
|
||||||
),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
options: Options(responseType: ResponseType.bytes),
|
||||||
);
|
);
|
||||||
return Uint8List.fromList(response.data!);
|
return Uint8List.fromList(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_ticker_response.dart';
|
import 'get_ticker_response.dart';
|
||||||
|
|
||||||
/// Fetches the current "Aktuelles" ticker post from
|
/// Fetches the current "Aktuelles" ticker post from
|
||||||
/// `GET /api/mobile/v1/ticker`. Bearer token is attached by the shared dio.
|
/// `GET /api/mobile/v1/ticker`. Bearer token is attached by the shared dio.
|
||||||
class GetTicker {
|
class GetTicker extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetTicker({super.dio});
|
||||||
|
|
||||||
GetTicker({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TickerResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(endpoint('ticker'));
|
||||||
Future<TickerResponse> run() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('ticker'),
|
|
||||||
);
|
|
||||||
return TickerResponse.fromJson(response.data!);
|
return TickerResponse.fromJson(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_ticker_nav_response.dart';
|
import 'get_ticker_nav_response.dart';
|
||||||
|
|
||||||
/// Fetches the filtered ticker page tree from
|
/// Fetches the filtered ticker page tree from
|
||||||
/// `GET /api/mobile/v1/ticker/pages`.
|
/// `GET /api/mobile/v1/ticker/pages`.
|
||||||
class GetTickerNav {
|
class GetTickerNav extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetTickerNav({super.dio});
|
||||||
|
|
||||||
GetTickerNav({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TickerNavResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
Future<TickerNavResponse> run() async {
|
endpoint('ticker/pages'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('ticker/pages'),
|
|
||||||
);
|
);
|
||||||
return TickerNavResponse.fromJson(response.data!);
|
return TickerNavResponse.fromJson(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../../../errors/ticker_content_unavailable_exception.dart';
|
import '../../../errors/ticker_content_unavailable_exception.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../errors/marianumconnect_error.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_ticker_page_response.dart';
|
import 'get_ticker_page_response.dart';
|
||||||
|
|
||||||
/// Fetches a single ticker page from
|
/// Fetches a single ticker page from
|
||||||
@@ -15,19 +14,17 @@ import 'get_ticker_page_response.dart';
|
|||||||
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
|
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
|
||||||
/// to a dedicated [TickerContentUnavailableException] carrying the browser
|
/// to a dedicated [TickerContentUnavailableException] carrying the browser
|
||||||
/// fallback URL, so the detail screen can offer "open in browser" instead of a
|
/// fallback URL, so the detail screen can offer "open in browser" instead of a
|
||||||
/// generic error.
|
/// generic error. The bespoke 404 handling is why this keeps its own try/catch
|
||||||
class GetTickerPage {
|
/// instead of the base [guard].
|
||||||
|
class GetTickerPage extends MarianumConnectQuery {
|
||||||
final String slug;
|
final String slug;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
GetTickerPage(this.slug, {Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
GetTickerPage(this.slug, {super.dio});
|
||||||
|
|
||||||
Future<TickerPageResponse> run() async {
|
Future<TickerPageResponse> run() async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
MarianumConnectEndpoint.resolve(
|
endpoint('ticker/pages/${Uri.encodeComponent(slug)}'),
|
||||||
'ticker/pages/${Uri.encodeComponent(slug)}',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return TickerPageResponse.fromJson(response.data!);
|
return TickerPageResponse.fromJson(response.data!);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ class TickerPageResponse {
|
|||||||
final String? hash;
|
final String? hash;
|
||||||
final String? webUrl;
|
final String? webUrl;
|
||||||
|
|
||||||
|
/// ISO timestamp the page was last published/updated (`ticker_pages.published_at`),
|
||||||
|
/// shown as "Aktualisiert am …" — the same date the web view displays. Null
|
||||||
|
/// when the page has never been published.
|
||||||
|
final String? publishedAt;
|
||||||
|
|
||||||
|
/// PROXIED_FILE only: ISO timestamp of the last successful re-fetch of the
|
||||||
|
/// file by the server proxy (`ticker_pages.proxy_last_success_at`) — the
|
||||||
|
/// document's data currency, shown as "Aktualisiert am …" instead of
|
||||||
|
/// [publishedAt]. Null for other kinds / files never fetched yet.
|
||||||
|
final String? fileFetchedAt;
|
||||||
|
|
||||||
TickerPageResponse({
|
TickerPageResponse({
|
||||||
required this.schemaVersion,
|
required this.schemaVersion,
|
||||||
this.slug,
|
this.slug,
|
||||||
@@ -52,6 +63,8 @@ class TickerPageResponse {
|
|||||||
this.filename,
|
this.filename,
|
||||||
this.hash,
|
this.hash,
|
||||||
this.webUrl,
|
this.webUrl,
|
||||||
|
this.publishedAt,
|
||||||
|
this.fileFetchedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory TickerPageResponse.fromJson(Map<String, dynamic> json) =>
|
factory TickerPageResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
|
|||||||
filename: json['filename'] as String?,
|
filename: json['filename'] as String?,
|
||||||
hash: json['hash'] as String?,
|
hash: json['hash'] as String?,
|
||||||
webUrl: json['webUrl'] as String?,
|
webUrl: json['webUrl'] as String?,
|
||||||
|
publishedAt: json['publishedAt'] as String?,
|
||||||
|
fileFetchedAt: json['fileFetchedAt'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
|
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
|
||||||
@@ -36,4 +38,6 @@ Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
|
|||||||
'filename': instance.filename,
|
'filename': instance.filename,
|
||||||
'hash': instance.hash,
|
'hash': instance.hash,
|
||||||
'webUrl': instance.webUrl,
|
'webUrl': instance.webUrl,
|
||||||
|
'publishedAt': instance.publishedAt,
|
||||||
|
'fileFetchedAt': instance.fileFetchedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
||||||
/// `GET /api/mobile/v1/ticker/pages/{slug}/file`.
|
/// `GET /api/mobile/v1/ticker/pages/{slug}/file`.
|
||||||
@@ -12,24 +10,16 @@ import '../../marianumconnect_endpoint.dart';
|
|||||||
/// Goes through the shared MC dio so the bearer token is attached automatically
|
/// Goes through the shared MC dio so the bearer token is attached automatically
|
||||||
/// (server enforces page visibility); the bytes are handed to `SfPdfViewer.memory`
|
/// (server enforces page visibility); the bytes are handed to `SfPdfViewer.memory`
|
||||||
/// so no auth header has to be plumbed into the viewer itself.
|
/// so no auth header has to be plumbed into the viewer itself.
|
||||||
class GetTickerPageFile {
|
class GetTickerPageFile extends MarianumConnectQuery {
|
||||||
final String slug;
|
final String slug;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
GetTickerPageFile(this.slug, {Dio? dio})
|
GetTickerPageFile(this.slug, {super.dio});
|
||||||
: _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<Uint8List> run() async {
|
Future<Uint8List> run() => guard(() async {
|
||||||
try {
|
final response = await dio.get<List<int>>(
|
||||||
final response = await _dio.get<List<int>>(
|
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
|
||||||
MarianumConnectEndpoint.resolve(
|
|
||||||
'ticker/pages/${Uri.encodeComponent(slug)}/file',
|
|
||||||
),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
options: Options(responseType: ResponseType.bytes),
|
||||||
);
|
);
|
||||||
return Uint8List.fromList(response.data!);
|
return Uint8List.fromList(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_ticker_sync_response.dart';
|
|
||||||
|
|
||||||
/// Fetches the ticker/nav change hashes from
|
|
||||||
/// `GET /api/mobile/v1/ticker/sync`.
|
|
||||||
class GetTickerSync {
|
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
GetTickerSync({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<TickerSyncResponse> run() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('ticker/sync'),
|
|
||||||
);
|
|
||||||
return TickerSyncResponse.fromJson(response.data!);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
|
|
||||||
part 'get_ticker_sync_response.g.dart';
|
|
||||||
|
|
||||||
/// Cheap change-detection poll from `GET /api/mobile/v1/ticker/sync`. Both
|
|
||||||
/// hashes let the app decide whether the ticker post and/or the page tree need
|
|
||||||
/// a full refetch without paying for the full payloads.
|
|
||||||
@JsonSerializable()
|
|
||||||
class TickerSyncResponse {
|
|
||||||
final String? tickerHash;
|
|
||||||
final String? navHash;
|
|
||||||
|
|
||||||
TickerSyncResponse({this.tickerHash, this.navHash});
|
|
||||||
|
|
||||||
factory TickerSyncResponse.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$TickerSyncResponseFromJson(json);
|
|
||||||
Map<String, dynamic> toJson() => _$TickerSyncResponseToJson(this);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'get_ticker_sync_response.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
TickerSyncResponse _$TickerSyncResponseFromJson(Map<String, dynamic> json) =>
|
|
||||||
TickerSyncResponse(
|
|
||||||
tickerHash: json['tickerHash'] as String?,
|
|
||||||
navHash: json['navHash'] as String?,
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$TickerSyncResponseToJson(TickerSyncResponse instance) =>
|
|
||||||
<String, dynamic>{
|
|
||||||
'tickerHash': instance.tickerHash,
|
|
||||||
'navHash': instance.navHash,
|
|
||||||
};
|
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Registers (upserts) this device's push subscription with MarianumConnect via
|
/// Registers (upserts) this device's push subscription with MarianumConnect via
|
||||||
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
|
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
|
||||||
/// device-identifier signature, stores the routing metadata and starts
|
/// device-identifier signature, stores the routing metadata and starts
|
||||||
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
|
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
|
||||||
class PushDeviceRegister {
|
class PushDeviceRegister extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
PushDeviceRegister({super.dio});
|
||||||
|
|
||||||
PushDeviceRegister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run({
|
Future<void> run({
|
||||||
required String deviceIdentifier,
|
required String deviceIdentifier,
|
||||||
@@ -21,10 +15,9 @@ class PushDeviceRegister {
|
|||||||
required String platform,
|
required String platform,
|
||||||
required String registrationType,
|
required String registrationType,
|
||||||
String? appVersion,
|
String? appVersion,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
await dio.put<void>(
|
||||||
await _dio.put<void>(
|
endpoint('me/push-device'),
|
||||||
MarianumConnectEndpoint.resolve('me/push-device'),
|
|
||||||
data: {
|
data: {
|
||||||
'deviceIdentifier': deviceIdentifier,
|
'deviceIdentifier': deviceIdentifier,
|
||||||
'deviceIdentifierSignature': deviceIdentifierSignature,
|
'deviceIdentifierSignature': deviceIdentifierSignature,
|
||||||
@@ -37,8 +30,5 @@ class PushDeviceRegister {
|
|||||||
'appVersion': ?appVersion,
|
'appVersion': ?appVersion,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Triggers a test push to all of the current user's registered devices via
|
/// Triggers a test push to all of the current user's registered devices via
|
||||||
/// `POST /api/mobile/v1/me/push-device/test`. Returns the number of devices the
|
/// `POST /api/mobile/v1/me/push-device/test`. Returns the number of devices the
|
||||||
/// backend dispatched to (0 when none are registered).
|
/// backend dispatched to (0 when none are registered).
|
||||||
class PushDeviceTest {
|
class PushDeviceTest extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
PushDeviceTest({super.dio});
|
||||||
|
|
||||||
PushDeviceTest({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<int> run() => guard(() async {
|
||||||
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
Future<int> run() async {
|
endpoint('me/push-device/test'),
|
||||||
try {
|
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('me/push-device/test'),
|
|
||||||
);
|
);
|
||||||
return (response.data?['devices'] as num?)?.toInt() ?? 0;
|
return (response.data?['devices'] as num?)?.toInt() ?? 0;
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Removes this device's push subscription from MarianumConnect via
|
/// Removes this device's push subscription from MarianumConnect via
|
||||||
/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent
|
/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent
|
||||||
/// (204 even when the row is already gone).
|
/// (204 even when the row is already gone).
|
||||||
class PushDeviceUnregister {
|
class PushDeviceUnregister extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
PushDeviceUnregister({super.dio});
|
||||||
|
|
||||||
PushDeviceUnregister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<void> run({required String deviceIdentifier}) => guard(() async {
|
||||||
|
await dio.delete<void>(
|
||||||
Future<void> run({required String deviceIdentifier}) async {
|
endpoint('me/push-device'),
|
||||||
try {
|
|
||||||
await _dio.delete<void>(
|
|
||||||
MarianumConnectEndpoint.resolve('me/push-device'),
|
|
||||||
queryParameters: {'deviceIdentifier': deviceIdentifier},
|
queryParameters: {'deviceIdentifier': deviceIdentifier},
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Sends a single client-side error report to MarianumConnect
|
/// Sends a single client-side error report to MarianumConnect
|
||||||
/// (`POST client-errors`). The endpoint is public, so reports that happen
|
/// (`POST client-errors`). The endpoint is public, so reports that happen
|
||||||
/// before login are still captured; when a bearer token is present the shared
|
/// before login are still captured; when a bearer token is present the shared
|
||||||
/// dio interceptor attaches it and the server attributes the report to that user.
|
/// dio interceptor attaches it and the server attributes the report to that user.
|
||||||
class ReportClientError {
|
class ReportClientError extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
ReportClientError({super.dio});
|
||||||
|
|
||||||
ReportClientError({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run({
|
Future<void> run({
|
||||||
required String errorType,
|
required String errorType,
|
||||||
@@ -21,10 +15,9 @@ class ReportClientError {
|
|||||||
String? platform,
|
String? platform,
|
||||||
String? appVersion,
|
String? appVersion,
|
||||||
String? deviceModel,
|
String? deviceModel,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
await dio.post<void>(
|
||||||
await _dio.post<void>(
|
endpoint('client-errors'),
|
||||||
MarianumConnectEndpoint.resolve('client-errors'),
|
|
||||||
data: {
|
data: {
|
||||||
'errorType': errorType,
|
'errorType': errorType,
|
||||||
'message': ?message,
|
'message': ?message,
|
||||||
@@ -35,8 +28,5 @@ class ReportClientError {
|
|||||||
'deviceModel': ?deviceModel,
|
'deviceModel': ?deviceModel,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,32 +3,26 @@ import 'dart:io';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
|
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
|
||||||
/// The optional screenshot is sent base64-encoded. Replaces the legacy mhsl.eu
|
/// The optional screenshot is sent base64-encoded. Replaces the legacy mhsl.eu
|
||||||
/// `server/feedback` endpoint — the user no longer needs to be sent explicitly,
|
/// `server/feedback` endpoint — the user no longer needs to be sent explicitly,
|
||||||
/// the bearer token identifies them.
|
/// the bearer token identifies them.
|
||||||
class SubmitFeedback {
|
class SubmitFeedback extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
SubmitFeedback({super.dio});
|
||||||
|
|
||||||
SubmitFeedback({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run({
|
Future<void> run({
|
||||||
required String message,
|
required String message,
|
||||||
Uint8List? screenshot,
|
Uint8List? screenshot,
|
||||||
String screenshotContentType = 'image/png',
|
String screenshotContentType = 'image/png',
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
|
||||||
final package = await PackageInfo.fromPlatform();
|
final package = await PackageInfo.fromPlatform();
|
||||||
final screenshotBase64 = screenshot != null ? base64Encode(screenshot) : null;
|
final screenshotBase64 = screenshot != null ? base64Encode(screenshot) : null;
|
||||||
await _dio.post<void>(
|
await dio.post<void>(
|
||||||
MarianumConnectEndpoint.resolve('me/feedback'),
|
endpoint('me/feedback'),
|
||||||
data: {
|
data: {
|
||||||
'message': message,
|
'message': message,
|
||||||
'screenshot': ?screenshotBase64,
|
'screenshot': ?screenshotBase64,
|
||||||
@@ -39,10 +33,7 @@ class SubmitFeedback {
|
|||||||
'deviceModel': await _deviceModel(),
|
'deviceModel': await _deviceModel(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? _platform() {
|
static String? _platform() {
|
||||||
if (Platform.isAndroid) return 'android';
|
if (Platform.isAndroid) return 'android';
|
||||||
|
|||||||
@@ -3,14 +3,11 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
import '../../../../push/push_registration_store.dart';
|
import '../../../../push/push_registration_store.dart';
|
||||||
import '../../../../push/push_registration_type.dart';
|
import '../../../../push/push_registration_type.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'telemetry_device_id.dart';
|
import 'telemetry_device_id.dart';
|
||||||
|
|
||||||
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
||||||
@@ -19,10 +16,8 @@ import 'telemetry_device_id.dart';
|
|||||||
/// (so a fresh registration isn't under-reported until the next launch).
|
/// (so a fresh registration isn't under-reported until the next launch).
|
||||||
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
||||||
/// mhsl.eu `server/userIndex/update` call.
|
/// mhsl.eu `server/userIndex/update` call.
|
||||||
class TelemetryHeartbeat {
|
class TelemetryHeartbeat extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TelemetryHeartbeat({super.dio});
|
||||||
|
|
||||||
TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
||||||
/// send never disrupts app start. Used from the app shell's initState and
|
/// send never disrupts app start. Used from the app shell's initState and
|
||||||
@@ -35,8 +30,7 @@ class TelemetryHeartbeat {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> send({required bool notificationsEnabled}) async {
|
Future<void> send({required bool notificationsEnabled}) => guard(() async {
|
||||||
try {
|
|
||||||
final info = DeviceInfoPlugin();
|
final info = DeviceInfoPlugin();
|
||||||
final package = await PackageInfo.fromPlatform();
|
final package = await PackageInfo.fromPlatform();
|
||||||
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
||||||
@@ -61,8 +55,8 @@ class TelemetryHeartbeat {
|
|||||||
raw = appleInfo.data;
|
raw = appleInfo.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _dio.post<void>(
|
await dio.post<void>(
|
||||||
MarianumConnectEndpoint.resolve('me/telemetry'),
|
endpoint('me/telemetry'),
|
||||||
data: {
|
data: {
|
||||||
'deviceIdentifier': deviceIdentifier,
|
'deviceIdentifier': deviceIdentifier,
|
||||||
// `pushDeviceIdentifier` reflects a *completed* registration and is
|
// `pushDeviceIdentifier` reflects a *completed* registration and is
|
||||||
@@ -79,8 +73,5 @@ class TelemetryHeartbeat {
|
|||||||
'deviceInfo': jsonEncode(raw),
|
'deviceInfo': jsonEncode(raw),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-17
@@ -1,23 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
class TimetableCustomEventsAdd {
|
class TimetableCustomEventsAdd extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableCustomEventsAdd({super.dio});
|
||||||
|
|
||||||
TimetableCustomEventsAdd({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<void> run(CustomTimetableEvent event) => guard(() async {
|
||||||
|
await dio.post<void>(
|
||||||
Future<void> run(CustomTimetableEvent event) async {
|
endpoint('timetable/custom-events'),
|
||||||
try {
|
|
||||||
await _dio.post<void>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/custom-events'),
|
|
||||||
data: event.toJson(),
|
data: event.toJson(),
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-17
@@ -1,23 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
class TimetableCustomEventsGet {
|
class TimetableCustomEventsGet extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableCustomEventsGet({super.dio});
|
||||||
|
|
||||||
TimetableCustomEventsGet({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<GetCustomTimetableEventResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
Future<GetCustomTimetableEventResponse> run() async {
|
endpoint('timetable/custom-events'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/custom-events'),
|
|
||||||
);
|
);
|
||||||
return GetCustomTimetableEventResponse.fromJson(response.data!);
|
return GetCustomTimetableEventResponse.fromJson(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-19
@@ -1,22 +1,9 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
class TimetableCustomEventsRemove extends MarianumConnectQuery {
|
||||||
import '../../marianumconnect_api.dart';
|
TimetableCustomEventsRemove({super.dio});
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
class TimetableCustomEventsRemove {
|
Future<void> run(String id) => guard(() async {
|
||||||
final Dio _dio;
|
await dio.delete<void>(endpoint('timetable/custom-events/$id'));
|
||||||
|
});
|
||||||
TimetableCustomEventsRemove({Dio? dio})
|
|
||||||
: _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run(String id) async {
|
|
||||||
try {
|
|
||||||
await _dio.delete<void>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/custom-events/$id'),
|
|
||||||
);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-18
@@ -1,24 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
class TimetableCustomEventsUpdate {
|
class TimetableCustomEventsUpdate extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableCustomEventsUpdate({super.dio});
|
||||||
|
|
||||||
TimetableCustomEventsUpdate({Dio? dio})
|
Future<void> run(String id, CustomTimetableEvent event) => guard(() async {
|
||||||
: _dio = dio ?? MarianumConnectApi.dio();
|
await dio.put<void>(
|
||||||
|
endpoint('timetable/custom-events/$id'),
|
||||||
Future<void> run(String id, CustomTimetableEvent event) async {
|
|
||||||
try {
|
|
||||||
await _dio.put<void>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/custom-events/$id'),
|
|
||||||
data: event.toJson(),
|
data: event.toJson(),
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_classes_response.dart';
|
import 'timetable_get_classes_response.dart';
|
||||||
|
|
||||||
class TimetableGetClasses {
|
class TimetableGetClasses extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetClasses({super.dio});
|
||||||
|
|
||||||
TimetableGetClasses({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetClassesResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetClassesResponse> run() async {
|
endpoint('timetable/elements/classes'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<List<dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/elements/classes'),
|
|
||||||
);
|
);
|
||||||
final list = response.data!
|
final list = response.data!
|
||||||
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
return TimetableGetClassesResponse(result: list);
|
return TimetableGetClassesResponse(result: list);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-17
@@ -1,35 +1,25 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import '../timetable_get_week/timetable_get_week_response.dart';
|
import '../timetable_get_week/timetable_get_week_response.dart';
|
||||||
import 'timetable_element_type.dart';
|
import 'timetable_element_type.dart';
|
||||||
|
|
||||||
/// Fetches a foreign element's weekly timetable from
|
/// Fetches a foreign element's weekly timetable from
|
||||||
/// `timetable/{student|teacher|room|class}/{id}`. The response shape is
|
/// `timetable/{student|teacher|room|class}/{id}`. The response shape is
|
||||||
/// identical to `timetable/me`, so [TimetableGetWeekResponse] is reused.
|
/// identical to `timetable/me`, so [TimetableGetWeekResponse] is reused.
|
||||||
class TimetableGetElementWeek {
|
class TimetableGetElementWeek extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetElementWeek({super.dio});
|
||||||
|
|
||||||
TimetableGetElementWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<TimetableGetWeekResponse> run({
|
Future<TimetableGetWeekResponse> run({
|
||||||
required TimetableElementType type,
|
required TimetableElementType type,
|
||||||
required int id,
|
required int id,
|
||||||
required DateTime from,
|
required DateTime from,
|
||||||
required DateTime until,
|
required DateTime until,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
endpoint('timetable/${type.pathSegment}/$id'),
|
||||||
MarianumConnectEndpoint.resolve('timetable/${type.pathSegment}/$id'),
|
|
||||||
queryParameters: {'from': _format(from), 'until': _format(until)},
|
queryParameters: {'from': _format(from), 'until': _format(until)},
|
||||||
);
|
);
|
||||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
return TimetableGetWeekResponse.fromJson(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _format(DateTime d) =>
|
String _format(DateTime d) =>
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_holidays_response.dart';
|
import 'timetable_get_holidays_response.dart';
|
||||||
|
|
||||||
class TimetableGetHolidays {
|
class TimetableGetHolidays extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetHolidays({super.dio});
|
||||||
|
|
||||||
TimetableGetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetHolidaysResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetHolidaysResponse> run() async {
|
endpoint('timetable/holidays'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<List<dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/holidays'),
|
|
||||||
);
|
);
|
||||||
final list = response.data!
|
final list = response.data!
|
||||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
return TimetableGetHolidaysResponse(result: list);
|
return TimetableGetHolidaysResponse(result: list);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_rooms_response.dart';
|
import 'timetable_get_rooms_response.dart';
|
||||||
|
|
||||||
class TimetableGetRooms {
|
class TimetableGetRooms extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetRooms({super.dio});
|
||||||
|
|
||||||
TimetableGetRooms({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetRoomsResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(endpoint('timetable/rooms'));
|
||||||
Future<TimetableGetRoomsResponse> run() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.get<List<dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/rooms'),
|
|
||||||
);
|
|
||||||
final list = response.data!
|
final list = response.data!
|
||||||
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
|
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
return TimetableGetRoomsResponse(result: list);
|
return TimetableGetRoomsResponse(result: list);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-17
@@ -1,23 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_schoolyear_response.dart';
|
import 'timetable_get_schoolyear_response.dart';
|
||||||
|
|
||||||
class TimetableGetSchoolyear {
|
class TimetableGetSchoolyear extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetSchoolyear({super.dio});
|
||||||
|
|
||||||
TimetableGetSchoolyear({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetSchoolyearResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
Future<TimetableGetSchoolyearResponse> run() async {
|
endpoint('timetable/schoolyear'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/schoolyear'),
|
|
||||||
);
|
);
|
||||||
return TimetableGetSchoolyearResponse.fromJson(response.data!);
|
return TimetableGetSchoolyearResponse.fromJson(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_students_response.dart';
|
import 'timetable_get_students_response.dart';
|
||||||
|
|
||||||
class TimetableGetStudents {
|
class TimetableGetStudents extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetStudents({super.dio});
|
||||||
|
|
||||||
TimetableGetStudents({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetStudentsResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetStudentsResponse> run() async {
|
endpoint('timetable/elements/students'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<List<dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/elements/students'),
|
|
||||||
);
|
);
|
||||||
final list = response.data!
|
final list = response.data!
|
||||||
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
|
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
return TimetableGetStudentsResponse(result: list);
|
return TimetableGetStudentsResponse(result: list);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_subjects_response.dart';
|
import 'timetable_get_subjects_response.dart';
|
||||||
|
|
||||||
class TimetableGetSubjects {
|
class TimetableGetSubjects extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetSubjects({super.dio});
|
||||||
|
|
||||||
TimetableGetSubjects({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetSubjectsResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetSubjectsResponse> run() async {
|
endpoint('timetable/subjects'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<List<dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/subjects'),
|
|
||||||
);
|
);
|
||||||
final list = response.data!
|
final list = response.data!
|
||||||
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
|
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
return TimetableGetSubjectsResponse(result: list);
|
return TimetableGetSubjectsResponse(result: list);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_teachers_response.dart';
|
import 'timetable_get_teachers_response.dart';
|
||||||
|
|
||||||
class TimetableGetTeachers {
|
class TimetableGetTeachers extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetTeachers({super.dio});
|
||||||
|
|
||||||
TimetableGetTeachers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetTeachersResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetTeachersResponse> run() async {
|
endpoint('timetable/elements/teachers'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<List<dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/elements/teachers'),
|
|
||||||
);
|
);
|
||||||
final list = response.data!
|
final list = response.data!
|
||||||
.map(
|
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
|
||||||
(e) =>
|
|
||||||
McTimetableTeacherElement.fromJson(e as Map<String, dynamic>),
|
|
||||||
)
|
|
||||||
.toList();
|
.toList();
|
||||||
return TimetableGetTeachersResponse(result: list);
|
return TimetableGetTeachersResponse(result: list);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_timegrid_response.dart';
|
import 'timetable_get_timegrid_response.dart';
|
||||||
|
|
||||||
class TimetableGetTimegrid {
|
class TimetableGetTimegrid extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetTimegrid({super.dio});
|
||||||
|
|
||||||
TimetableGetTimegrid({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetTimegridResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetTimegridResponse> run() async {
|
endpoint('timetable/timegrid'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<List<dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/timegrid'),
|
|
||||||
);
|
);
|
||||||
final list = response.data!
|
final list = response.data!
|
||||||
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
|
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
return TimetableGetTimegridResponse(result: list);
|
return TimetableGetTimegridResponse(result: list);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,19 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_week_response.dart';
|
import 'timetable_get_week_response.dart';
|
||||||
|
|
||||||
class TimetableGetWeek {
|
class TimetableGetWeek extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetWeek({super.dio});
|
||||||
|
|
||||||
TimetableGetWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<TimetableGetWeekResponse> run({
|
Future<TimetableGetWeekResponse> run({
|
||||||
required DateTime from,
|
required DateTime from,
|
||||||
required DateTime until,
|
required DateTime until,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
endpoint('timetable/me'),
|
||||||
MarianumConnectEndpoint.resolve('timetable/me'),
|
queryParameters: {'from': _format(from), 'until': _format(until)},
|
||||||
queryParameters: {
|
|
||||||
'from': _format(from),
|
|
||||||
'until': _format(until),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
return TimetableGetWeekResponse.fromJson(response.data!);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _format(DateTime d) =>
|
String _format(DateTime d) =>
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|||||||
+7
-18
@@ -1,25 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Entfernt die persönliche Farbe eines Fachs (Fach fällt auf die Status-Farbe
|
/// Entfernt die persönliche Farbe eines Fachs (Fach fällt auf die Status-Farbe
|
||||||
/// zurück). Schlüssel ist das Fach-Kürzel (`shortName`).
|
/// zurück). Schlüssel ist das Fach-Kürzel (`shortName`).
|
||||||
class TimetableSubjectColorRemove {
|
class TimetableSubjectColorRemove extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableSubjectColorRemove({super.dio});
|
||||||
|
|
||||||
TimetableSubjectColorRemove({Dio? dio})
|
Future<void> run(String subjectShort) => guard(() async {
|
||||||
: _dio = dio ?? MarianumConnectApi.dio();
|
await dio.delete<void>(
|
||||||
|
endpoint('timetable/subject-colors'),
|
||||||
Future<void> run(String subjectShort) async {
|
|
||||||
try {
|
|
||||||
await _dio.delete<void>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/subject-colors'),
|
|
||||||
queryParameters: {'subject': subjectShort},
|
queryParameters: {'subject': subjectShort},
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-17
@@ -1,24 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Setzt (oder überschreibt) die persönliche Farbe eines Fachs. Der Schlüssel
|
/// Setzt (oder überschreibt) die persönliche Farbe eines Fachs. Der Schlüssel
|
||||||
/// ist das Fach-Kürzel (`shortName`); die Farbe ist ein Palette-Name.
|
/// ist das Fach-Kürzel (`shortName`); die Farbe ist ein Palette-Name.
|
||||||
class TimetableSubjectColorSet {
|
class TimetableSubjectColorSet extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableSubjectColorSet({super.dio});
|
||||||
|
|
||||||
TimetableSubjectColorSet({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<void> run(String subjectShort, String color) => guard(() async {
|
||||||
|
await dio.put<void>(
|
||||||
Future<void> run(String subjectShort, String color) async {
|
endpoint('timetable/subject-colors'),
|
||||||
try {
|
|
||||||
await _dio.put<void>(
|
|
||||||
MarianumConnectEndpoint.resolve('timetable/subject-colors'),
|
|
||||||
data: {'subject': subjectShort, 'color': color},
|
data: {'subject': subjectShort, 'color': color},
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'user_search_response.dart';
|
import 'user_search_response.dart';
|
||||||
|
|
||||||
/// Searches active users (students, teachers, staff) via the MarianumConnect
|
/// Searches active users (students, teachers, staff) via the MarianumConnect
|
||||||
/// mobile API. Returns each match's Nextcloud username plus role, so the Talk
|
/// mobile API. Returns each match's Nextcloud username plus role, so the Talk
|
||||||
/// search can start a direct chat and label results without hitting Nextcloud.
|
/// search can start a direct chat and label results without hitting Nextcloud.
|
||||||
class UserSearch {
|
class UserSearch extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
UserSearch({super.dio});
|
||||||
|
|
||||||
UserSearch({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<UserSearchResponse> run(String query) => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<UserSearchResponse> run(String query) async {
|
endpoint('users/search'),
|
||||||
try {
|
|
||||||
final response = await _dio.get<List<dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('users/search'),
|
|
||||||
queryParameters: {'q': query},
|
queryParameters: {'q': query},
|
||||||
);
|
);
|
||||||
final list = response.data!
|
final list = response.data!
|
||||||
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
|
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
return UserSearchResponse(result: list);
|
return UserSearchResponse(result: list);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ extension DateTimeFormatting on DateTime {
|
|||||||
|
|
||||||
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
|
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
|
||||||
|
|
||||||
|
/// Compact `yyyyMMdd` key, e.g. to identify a week-start in timetable caches.
|
||||||
|
String weekKey() => Jiffy.parseFromDateTime(this).format(pattern: 'yyyyMMdd');
|
||||||
|
|
||||||
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
|
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
|
||||||
|
|
||||||
String formatDateRelativeShort({DateTime? now}) {
|
String formatDateRelativeShort({DateTime? now}) {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
extension TimeOfDayExt on TimeOfDay {
|
|
||||||
bool isBefore(TimeOfDay other) => hour < other.hour && minute < other.minute;
|
|
||||||
|
|
||||||
bool isAfter(TimeOfDay other) => hour > other.hour && minute > other.minute;
|
|
||||||
|
|
||||||
TimeOfDay add({int hours = 0, int minutes = 0}) =>
|
|
||||||
replacing(hour: hour + hours, minute: minute + minutes);
|
|
||||||
}
|
|
||||||
@@ -51,6 +51,7 @@ import 'utils/downloads/download_manager.dart';
|
|||||||
import 'view/login/login.dart';
|
import 'view/login/login.dart';
|
||||||
import 'view/login/post_login_splash.dart';
|
import 'view/login/post_login_splash.dart';
|
||||||
import 'widget/app_progress_indicator.dart';
|
import 'widget/app_progress_indicator.dart';
|
||||||
|
import 'widget/avatar_disk_cache.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
import 'widget/debug/cache_view.dart';
|
import 'widget/debug/cache_view.dart';
|
||||||
import 'widget/downloads/download_tray.dart';
|
import 'widget/downloads/download_tray.dart';
|
||||||
@@ -150,6 +151,11 @@ Future<void> main() async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the avatar cache directory ahead of the first avatar render so the
|
||||||
|
// synchronous disk read hits and cold-start avatars appear without a blank
|
||||||
|
// placeholder flash.
|
||||||
|
AvatarDiskCache.instance.warmUp();
|
||||||
|
|
||||||
if (kReleaseMode) {
|
if (kReleaseMode) {
|
||||||
ErrorWidget.builder = (error) => Material(
|
ErrorWidget.builder = (error) => Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ import '../view/pages/talk/talk_navigator.dart';
|
|||||||
import '../view/pages/ticker/ticker_page_view.dart';
|
import '../view/pages/ticker/ticker_page_view.dart';
|
||||||
import '../view/pages/timetable/custom_events/custom_events_view.dart';
|
import '../view/pages/timetable/custom_events/custom_events_view.dart';
|
||||||
import '../view/pages/timetable/subject_colors/subject_colors_view.dart';
|
import '../view/pages/timetable/subject_colors/subject_colors_view.dart';
|
||||||
|
import '../widget/avatar_crop_page.dart';
|
||||||
import '../widget/debug/cache_view.dart';
|
import '../widget/debug/cache_view.dart';
|
||||||
import '../widget/file_viewer.dart';
|
import '../widget/file_viewer.dart';
|
||||||
|
import '../widget/large_profile_picture_view.dart';
|
||||||
import '../widget/user_avatar.dart';
|
import '../widget/user_avatar.dart';
|
||||||
|
|
||||||
/// Single entry point for full-page navigations. Dialogs and bottom sheets
|
/// Single entry point for full-page navigations. Dialogs and bottom sheets
|
||||||
@@ -95,6 +97,30 @@ class AppRoutes {
|
|||||||
pushScreen(context, withNavBar: false, screen: const SubjectColorsView());
|
pushScreen(context, withNavBar: false, screen: const SubjectColorsView());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the full-screen cropper on [imageBytes] and resolves to the cropped
|
||||||
|
/// bytes (or null if cancelled). [aspectRatio] defaults to 1:1 for avatars;
|
||||||
|
/// pass null for a free-form crop (e.g. chat backgrounds).
|
||||||
|
static Future<Uint8List?> openAvatarCrop(
|
||||||
|
BuildContext context, {
|
||||||
|
required Uint8List imageBytes,
|
||||||
|
double? aspectRatio = 1,
|
||||||
|
}) {
|
||||||
|
return Navigator.of(context).push<Uint8List>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (_) =>
|
||||||
|
AvatarCropPage(imageBytes: imageBytes, aspectRatio: aspectRatio),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the tappable, zoomable profile-picture viewer for [id].
|
||||||
|
static void openLargeProfilePicture(BuildContext context, String id) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => LargeProfilePictureView(id: id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens the picker for choosing a foreign timetable element and resolves to
|
/// Opens the picker for choosing a foreign timetable element and resolves to
|
||||||
/// the selected element (or null if dismissed). The timetable view renders
|
/// the selected element (or null if dismissed). The timetable view renders
|
||||||
/// the chosen plan inline. Gated behind the `viewForeignTimetables`
|
/// the chosen plan inline. Gated behind the `viewForeignTimetables`
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../infrastructure/data_loader/data_loader.dart';
|
|
||||||
|
|
||||||
abstract class MhslDataLoader<TResult> extends DataLoader<TResult> {
|
|
||||||
MhslDataLoader()
|
|
||||||
: super(
|
|
||||||
Dio(BaseOptions(baseUrl: 'https://mhsl.eu/marianum/marianummobile/')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
import '../../../../../extensions/date_time.dart';
|
import '../../../../../extensions/date_time.dart';
|
||||||
@@ -24,7 +22,6 @@ class ForeignTimetableBloc
|
|||||||
TimetableState,
|
TimetableState,
|
||||||
ForeignTimetableRepository
|
ForeignTimetableRepository
|
||||||
> {
|
> {
|
||||||
static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd');
|
|
||||||
|
|
||||||
final TimetableElementType type;
|
final TimetableElementType type;
|
||||||
// Named `elementId` rather than `id` to avoid shadowing HydratedMixin's
|
// Named `elementId` rather than `id` to avoid shadowing HydratedMixin's
|
||||||
@@ -183,7 +180,7 @@ class ForeignTimetableBloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
|
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
|
||||||
final key = _weekKeyFormat.format(weekStart);
|
final key = weekStart.weekKey();
|
||||||
add(
|
add(
|
||||||
Emit((s) {
|
Emit((s) {
|
||||||
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
|
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import '../../../../../api/errors/ticker_content_unavailable_exception.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
|
import '../repository/ticker_page_repository.dart';
|
||||||
|
import 'ticker_page_event.dart';
|
||||||
|
import 'ticker_page_state.dart';
|
||||||
|
|
||||||
|
/// Per-slug loadable bloc for a single ticker page; [id] is the slug so each
|
||||||
|
/// page keeps its own hydrated cache entry.
|
||||||
|
class TickerPageBloc
|
||||||
|
extends
|
||||||
|
LoadableHydratedBloc<
|
||||||
|
TickerPageEvent,
|
||||||
|
TickerPageState,
|
||||||
|
TickerPageRepository
|
||||||
|
> {
|
||||||
|
final String slug;
|
||||||
|
|
||||||
|
TickerPageBloc(this.slug);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id => slug;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> gatherData() async {
|
||||||
|
try {
|
||||||
|
final page = await repo.getPage(slug);
|
||||||
|
add(DataGathered((state) => state.copyWith(page: page)));
|
||||||
|
} on TickerContentUnavailableException catch (e) {
|
||||||
|
// Content, not error: a content-less page keeps the "open in browser"
|
||||||
|
// branch and stays cached offline.
|
||||||
|
add(
|
||||||
|
DataGathered(
|
||||||
|
(state) => state.copyWith(
|
||||||
|
page: TickerPageResponse(
|
||||||
|
schemaVersion: 1,
|
||||||
|
slug: slug,
|
||||||
|
kind: TickerPageKind.content,
|
||||||
|
webUrl: e.webUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TickerPageRepository repository() => TickerPageRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TickerPageState fromNothing() => const TickerPageState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TickerPageState fromStorage(Map<String, dynamic> json) =>
|
||||||
|
TickerPageState.fromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? toStorage(TickerPageState state) => state.toJson();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
|
import 'ticker_page_state.dart';
|
||||||
|
|
||||||
|
sealed class TickerPageEvent extends LoadableHydratedBlocEvent<TickerPageState> {}
|
||||||
|
|
||||||
|
class TickerPageLoadEvent extends TickerPageEvent {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||||
|
|
||||||
|
part 'ticker_page_state.freezed.dart';
|
||||||
|
part 'ticker_page_state.g.dart';
|
||||||
|
|
||||||
|
/// Hydrated per-slug state of a single ticker page. PROXIED_FILE bytes are not
|
||||||
|
/// cached here — only [page] metadata persists; the PDF is fetched live.
|
||||||
|
@freezed
|
||||||
|
abstract class TickerPageState with _$TickerPageState {
|
||||||
|
const factory TickerPageState({TickerPageResponse? page}) = _TickerPageState;
|
||||||
|
|
||||||
|
factory TickerPageState.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$TickerPageStateFromJson(json);
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'ticker_page_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$TickerPageState {
|
||||||
|
|
||||||
|
TickerPageResponse? get page;
|
||||||
|
/// Create a copy of TickerPageState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$TickerPageStateCopyWith<TickerPageState> get copyWith => _$TickerPageStateCopyWithImpl<TickerPageState>(this as TickerPageState, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this TickerPageState to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is TickerPageState&&(identical(other.page, page) || other.page == page));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,page);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'TickerPageState(page: $page)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $TickerPageStateCopyWith<$Res> {
|
||||||
|
factory $TickerPageStateCopyWith(TickerPageState value, $Res Function(TickerPageState) _then) = _$TickerPageStateCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
TickerPageResponse? page
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$TickerPageStateCopyWithImpl<$Res>
|
||||||
|
implements $TickerPageStateCopyWith<$Res> {
|
||||||
|
_$TickerPageStateCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final TickerPageState _self;
|
||||||
|
final $Res Function(TickerPageState) _then;
|
||||||
|
|
||||||
|
/// Create a copy of TickerPageState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? page = freezed,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
|
||||||
|
as TickerPageResponse?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [TickerPageState].
|
||||||
|
extension TickerPageStatePatterns on TickerPageState {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _TickerPageState value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _TickerPageState() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _TickerPageState value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _TickerPageState():
|
||||||
|
return $default(_that);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _TickerPageState value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _TickerPageState() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( TickerPageResponse? page)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _TickerPageState() when $default != null:
|
||||||
|
return $default(_that.page);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( TickerPageResponse? page) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _TickerPageState():
|
||||||
|
return $default(_that.page);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( TickerPageResponse? page)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _TickerPageState() when $default != null:
|
||||||
|
return $default(_that.page);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _TickerPageState implements TickerPageState {
|
||||||
|
const _TickerPageState({this.page});
|
||||||
|
factory _TickerPageState.fromJson(Map<String, dynamic> json) => _$TickerPageStateFromJson(json);
|
||||||
|
|
||||||
|
@override final TickerPageResponse? page;
|
||||||
|
|
||||||
|
/// Create a copy of TickerPageState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$TickerPageStateCopyWith<_TickerPageState> get copyWith => __$TickerPageStateCopyWithImpl<_TickerPageState>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$TickerPageStateToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TickerPageState&&(identical(other.page, page) || other.page == page));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,page);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'TickerPageState(page: $page)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$TickerPageStateCopyWith<$Res> implements $TickerPageStateCopyWith<$Res> {
|
||||||
|
factory _$TickerPageStateCopyWith(_TickerPageState value, $Res Function(_TickerPageState) _then) = __$TickerPageStateCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
TickerPageResponse? page
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$TickerPageStateCopyWithImpl<$Res>
|
||||||
|
implements _$TickerPageStateCopyWith<$Res> {
|
||||||
|
__$TickerPageStateCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _TickerPageState _self;
|
||||||
|
final $Res Function(_TickerPageState) _then;
|
||||||
|
|
||||||
|
/// Create a copy of TickerPageState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? page = freezed,}) {
|
||||||
|
return _then(_TickerPageState(
|
||||||
|
page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
|
||||||
|
as TickerPageResponse?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'ticker_page_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_TickerPageState _$TickerPageStateFromJson(Map<String, dynamic> json) =>
|
||||||
|
_TickerPageState(
|
||||||
|
page: json['page'] == null
|
||||||
|
? null
|
||||||
|
: TickerPageResponse.fromJson(json['page'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$TickerPageStateToJson(_TickerPageState instance) =>
|
||||||
|
<String, dynamic>{'page': instance.page};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../../../../../api/demo/data/demo_ticker.dart';
|
||||||
|
import '../../../../../api/demo/demo_mode.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart';
|
||||||
|
import '../../../infrastructure/repository/repository.dart';
|
||||||
|
import '../bloc/ticker_page_state.dart';
|
||||||
|
|
||||||
|
/// Split from [TickerRepository] because the loadable base binds a repository
|
||||||
|
/// to its state type, and the per-page bloc is typed on [TickerPageState].
|
||||||
|
class TickerPageRepository extends Repository<TickerPageState> {
|
||||||
|
Future<TickerPageResponse> getPage(String slug) {
|
||||||
|
if (DemoMode.active) return Future.value(DemoTicker.page(slug));
|
||||||
|
return GetTickerPage(slug).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> getPageFile(String slug) {
|
||||||
|
if (DemoMode.active) return Future.value(Uint8List(0));
|
||||||
|
return GetTickerPageFile(slug).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import '../../../../../api/demo/data/demo_ticker.dart';
|
import '../../../../../api/demo/data/demo_ticker.dart';
|
||||||
import '../../../../../api/demo/demo_mode.dart';
|
import '../../../../../api/demo/demo_mode.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker.dart';
|
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
|
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart';
|
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
|
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart';
|
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart';
|
|
||||||
import '../../../infrastructure/repository/repository.dart';
|
import '../../../infrastructure/repository/repository.dart';
|
||||||
import '../bloc/ticker_state.dart';
|
import '../bloc/ticker_state.dart';
|
||||||
|
|
||||||
@@ -22,14 +17,4 @@ class TickerRepository extends Repository<TickerState> {
|
|||||||
if (DemoMode.active) return Future.value(DemoTicker.nav());
|
if (DemoMode.active) return Future.value(DemoTicker.nav());
|
||||||
return GetTickerNav().run();
|
return GetTickerNav().run();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<TickerPageResponse> getPage(String slug) {
|
|
||||||
if (DemoMode.active) return Future.value(DemoTicker.page(slug));
|
|
||||||
return GetTickerPage(slug).run();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Uint8List> getPageFile(String slug) {
|
|
||||||
if (DemoMode.active) return Future.value(Uint8List(0));
|
|
||||||
return GetTickerPageFile(slug).run();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import '../../../../../api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart';
|
import '../../../../../api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
@@ -19,8 +17,6 @@ class TimetableBloc
|
|||||||
TimetableState,
|
TimetableState,
|
||||||
TimetableRepository
|
TimetableRepository
|
||||||
> {
|
> {
|
||||||
static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd');
|
|
||||||
|
|
||||||
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
/// Set by [retry] to force the next [gatherData] to bypass cache freshness
|
/// Set by [retry] to force the next [gatherData] to bypass cache freshness
|
||||||
@@ -247,7 +243,7 @@ class TimetableBloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
|
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
|
||||||
final key = _weekKeyFormat.format(weekStart);
|
final key = weekStart.weekKey();
|
||||||
add(
|
add(
|
||||||
Emit((s) {
|
Emit((s) {
|
||||||
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
|
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ class PostLoginSplash extends StatefulWidget {
|
|||||||
try {
|
try {
|
||||||
_darkComposition ??= await AssetLottie(_darkAsset).load();
|
_darkComposition ??= await AssetLottie(_darkAsset).load();
|
||||||
_lightComposition ??= await AssetLottie(_lightAsset).load();
|
_lightComposition ??= await AssetLottie(_lightAsset).load();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
// Precache is a best-effort warm-up; the splash falls back to loading the
|
||||||
|
// composition on demand. Log so a broken asset is at least diagnosable.
|
||||||
|
debugPrint('PostLoginSplash.precache failed: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final VoidCallback onComplete;
|
final VoidCallback onComplete;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../state/app/modules/files/bloc/files_bloc.dart';
|
import '../../../../state/app/modules/files/bloc/files_bloc.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
|
||||||
import '../../../../widget/demo_restricted.dart';
|
import '../../../../widget/demo_restricted.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/file_pick.dart';
|
import '../../../../widget/file_pick.dart';
|
||||||
|
import '../../../../widget/prompt_dialog.dart';
|
||||||
|
|
||||||
/// Opens the "Element hinzufügen" sheet (create folder, upload, take photo, …).
|
/// Opens the "Element hinzufügen" sheet (create folder, upload, take photo, …).
|
||||||
/// [onPickedFiles] receives selected/captured file paths (gallery, file picker
|
/// [onPickedFiles] receives selected/captured file paths (gallery, file picker
|
||||||
@@ -59,27 +59,15 @@ void showAddFileSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
|
void showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
|
||||||
final inputController = TextEditingController();
|
showPromptDialog(
|
||||||
showDialog(
|
context,
|
||||||
context: context,
|
title: 'Neuer Ordner',
|
||||||
builder: (dialogCtx) => AlertDialog(
|
confirmButton: 'Ordner erstellen',
|
||||||
title: const Text('Neuer Ordner'),
|
onConfirm: (name) async {
|
||||||
content: TextField(
|
if (name.isEmpty) {
|
||||||
controller: inputController,
|
|
||||||
decoration: const InputDecoration(labelText: 'Name'),
|
|
||||||
autofocus: true,
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
AsyncDialogAction(
|
|
||||||
confirmLabel: 'Ordner erstellen',
|
|
||||||
onConfirm: () async {
|
|
||||||
if (inputController.text.trim().isEmpty) {
|
|
||||||
throw Exception('Bitte einen Namen eingeben.');
|
throw Exception('Bitte einen Namen eingeben.');
|
||||||
}
|
}
|
||||||
await bloc.createFolder(inputController.text.trim());
|
await bloc.createFolder(name);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import '../../../../widget/demo_restricted.dart';
|
|||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/downloads/download_trigger.dart';
|
import '../../../../widget/downloads/download_trigger.dart';
|
||||||
import '../../../../widget/info_dialog.dart';
|
import '../../../../widget/info_dialog.dart';
|
||||||
|
import '../../../../widget/prompt_dialog.dart';
|
||||||
import '../../talk/widgets/highlighted_linkify.dart';
|
import '../../talk/widgets/highlighted_linkify.dart';
|
||||||
import '../sharing/share_sheet.dart';
|
import '../sharing/share_sheet.dart';
|
||||||
import 'file_details_sheet.dart';
|
import 'file_details_sheet.dart';
|
||||||
@@ -136,52 +137,30 @@ class _FileElementState extends State<FileElement>
|
|||||||
String _joinPath(String folder, String name, {required bool isDirectory}) =>
|
String _joinPath(String folder, String name, {required bool isDirectory}) =>
|
||||||
isDirectory ? '$folder$name/' : '$folder$name';
|
isDirectory ? '$folder$name/' : '$folder$name';
|
||||||
|
|
||||||
Future<void> _rename() async {
|
void _rename() {
|
||||||
if (guardDemoAction(context)) return;
|
if (guardDemoAction(context)) return;
|
||||||
final controller = TextEditingController(text: widget.file.name);
|
showPromptDialog(
|
||||||
try {
|
context,
|
||||||
final newName = await showDialog<String>(
|
title: 'Umbenennen',
|
||||||
context: context,
|
label: 'Neuer Name',
|
||||||
builder: (dialogCtx) => AlertDialog(
|
confirmButton: 'Umbenennen',
|
||||||
title: const Text('Umbenennen'),
|
initialValue: widget.file.name,
|
||||||
content: TextField(
|
onConfirm: (newName) async {
|
||||||
controller: controller,
|
if (newName.isEmpty || newName == widget.file.name) return;
|
||||||
decoration: const InputDecoration(labelText: 'Neuer Name'),
|
|
||||||
autofocus: true,
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(dialogCtx).pop(),
|
|
||||||
child: const Text('Abbrechen'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () =>
|
|
||||||
Navigator.of(dialogCtx).pop(controller.text.trim()),
|
|
||||||
child: const Text('Umbenennen'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (newName == null || newName.isEmpty || newName == widget.file.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final parent = _parentPathOf(widget.file.path);
|
final parent = _parentPathOf(widget.file.path);
|
||||||
final destination = _joinPath(
|
final destination = _joinPath(
|
||||||
parent,
|
parent,
|
||||||
newName,
|
newName,
|
||||||
isDirectory: widget.file.isDirectory,
|
isDirectory: widget.file.isDirectory,
|
||||||
);
|
);
|
||||||
await _runWebdavOp(() async {
|
|
||||||
final webdav = await WebdavApi.webdav;
|
final webdav = await WebdavApi.webdav;
|
||||||
await webdav.move(
|
await webdav.move(
|
||||||
PathUri.parse(widget.file.path),
|
PathUri.parse(widget.file.path),
|
||||||
PathUri.parse(destination),
|
PathUri.parse(destination),
|
||||||
);
|
);
|
||||||
}, errorTitle: 'Umbenennen fehlgeschlagen');
|
widget.refetch();
|
||||||
} finally {
|
},
|
||||||
controller.dispose();
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _putOnClipboard({required bool copy}) {
|
void _putOnClipboard({required bool copy}) {
|
||||||
@@ -218,19 +197,6 @@ class _FileElementState extends State<FileElement>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runWebdavOp(
|
|
||||||
Future<void> Function() action, {
|
|
||||||
required String errorTitle,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
widget.refetch();
|
|
||||||
} on Object catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
InfoDialog.show(context, e.toString(), title: errorTitle, copyable: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showActionSheet() {
|
void _showActionSheet() {
|
||||||
Haptics.longPress();
|
Haptics.longPress();
|
||||||
showDetailsBottomSheet(
|
showDetailsBottomSheet(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart';
|
import '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart';
|
||||||
|
import '../../../widget/app_progress_indicator.dart';
|
||||||
import '../../../widget/confirm_dialog.dart';
|
import '../../../widget/confirm_dialog.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ class _MessageViewState extends State<MessageView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: AppProgressIndicator.large());
|
||||||
}
|
}
|
||||||
return SfPdfViewer.memory(
|
return SfPdfViewer.memory(
|
||||||
snapshot.data!,
|
snapshot.data!,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
|
|||||||
import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../model/account_data.dart';
|
||||||
import '../../../../push/push_registration.dart';
|
import '../../../../push/push_registration.dart';
|
||||||
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import '../../../../state/app/modules/account/bloc/account_state.dart';
|
import '../../../../state/app/modules/account/bloc/account_state.dart';
|
||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
@@ -12,7 +13,6 @@ import '../../../../widget/async_action_button.dart';
|
|||||||
import '../../../../widget/avatar_actions_sheet.dart';
|
import '../../../../widget/avatar_actions_sheet.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/demo_restricted.dart';
|
import '../../../../widget/demo_restricted.dart';
|
||||||
import '../../../../widget/large_profile_picture_view.dart';
|
|
||||||
import '../../../../widget/user_avatar.dart';
|
import '../../../../widget/user_avatar.dart';
|
||||||
|
|
||||||
// Display-name is process-wide stable until the user logs out; cache it so
|
// Display-name is process-wide stable until the user logs out; cache it so
|
||||||
@@ -107,12 +107,8 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () =>
|
||||||
MaterialPageRoute<void>(
|
AppRoutes.openLargeProfilePicture(context, username),
|
||||||
builder: (_) =>
|
|
||||||
LargeProfilePictureView(id: username),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: UserAvatar(
|
child: UserAvatar(
|
||||||
key: ValueKey(_avatarVersion),
|
key: ValueKey(_avatarVersion),
|
||||||
id: username,
|
id: username,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
|||||||
import '../../../../storage/haptic_settings.dart';
|
import '../../../../storage/haptic_settings.dart';
|
||||||
import '../../../../theming/app_theme.dart';
|
import '../../../../theming/app_theme.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../../../../utils/haptics.dart';
|
||||||
|
import '../widgets/settings_dropdown_tile.dart';
|
||||||
|
|
||||||
class AppearanceSection extends StatelessWidget {
|
class AppearanceSection extends StatelessWidget {
|
||||||
const AppearanceSection({super.key});
|
const AppearanceSection({super.key});
|
||||||
@@ -14,58 +15,28 @@ class AppearanceSection extends StatelessWidget {
|
|||||||
final settings = context.watch<SettingsCubit>();
|
final settings = context.watch<SettingsCubit>();
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
SettingsDropdownTile<ThemeMode>(
|
||||||
leading: const Icon(Icons.dark_mode_outlined),
|
icon: Icons.dark_mode_outlined,
|
||||||
title: const Text('Farbgebung'),
|
title: 'Farbgebung',
|
||||||
trailing: DropdownButton<ThemeMode>(
|
|
||||||
value: settings.val().appTheme,
|
value: settings.val().appTheme,
|
||||||
icon: const Icon(Icons.arrow_drop_down),
|
options: ThemeMode.values,
|
||||||
items: ThemeMode.values
|
optionIcon: (e) => AppTheme.getDisplayOptions(e).icon,
|
||||||
.map(
|
optionLabel: (e) => AppTheme.getDisplayOptions(e).displayName,
|
||||||
(e) => DropdownMenuItem<ThemeMode>(
|
onChanged: (e) => settings.val(write: true).appTheme = e,
|
||||||
value: e,
|
|
||||||
enabled: e != settings.val().appTheme,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(AppTheme.getDisplayOptions(e).icon),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(AppTheme.getDisplayOptions(e).displayName),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
SettingsDropdownTile<HapticLevel>(
|
||||||
)
|
icon: Icons.vibration_outlined,
|
||||||
.toList(),
|
title: 'Haptisches Feedback',
|
||||||
onChanged: (e) => settings.val(write: true).appTheme = e!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.vibration_outlined),
|
|
||||||
title: const Text('Haptisches Feedback'),
|
|
||||||
trailing: DropdownButton<HapticLevel>(
|
|
||||||
value: settings.val().hapticSettings.level,
|
value: settings.val().hapticSettings.level,
|
||||||
icon: const Icon(Icons.arrow_drop_down),
|
options: HapticLevel.values,
|
||||||
items: HapticLevel.values
|
optionIcon: _hapticIcon,
|
||||||
.map(
|
optionLabel: _hapticLabel,
|
||||||
(e) => DropdownMenuItem<HapticLevel>(
|
|
||||||
value: e,
|
|
||||||
enabled: e != settings.val().hapticSettings.level,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(_hapticIcon(e)),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(_hapticLabel(e)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (e) {
|
onChanged: (e) {
|
||||||
settings.val(write: true).hapticSettings.level = e!;
|
settings.val(write: true).hapticSettings.level = e;
|
||||||
// Sofortiges Probe-Feedback in der neu gewählten Stufe.
|
// Sofortiges Probe-Feedback in der neu gewählten Stufe.
|
||||||
Haptics.longPress();
|
Haptics.longPress();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
|
|||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../storage/settings.dart' as model;
|
import '../../../../storage/settings.dart' as model;
|
||||||
import '../../../../utils/haptics.dart';
|
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/debug/cache_view.dart';
|
import '../../../../widget/debug/cache_view.dart';
|
||||||
import '../../../../widget/debug/json_viewer.dart';
|
import '../../../../widget/debug/json_viewer.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../widgets/endpoint_picker.dart';
|
import '../widgets/endpoint_picker.dart';
|
||||||
|
import '../widgets/settings_checkbox_tile.dart';
|
||||||
|
|
||||||
class DevToolsSection extends StatefulWidget {
|
class DevToolsSection extends StatefulWidget {
|
||||||
final SettingsCubit settings;
|
final SettingsCubit settings;
|
||||||
@@ -41,49 +41,32 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
|||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
SettingsCheckboxTile(
|
||||||
leading: const Icon(Icons.auto_graph_outlined),
|
icon: Icons.auto_graph_outlined,
|
||||||
title: const Text('Performance graph'),
|
title: 'Performance graph',
|
||||||
trailing: Checkbox(
|
|
||||||
value: dev.showPerformanceOverlay,
|
value: dev.showPerformanceOverlay,
|
||||||
onChanged: (e) {
|
onChanged: (e) => widget.settings
|
||||||
Haptics.selection();
|
|
||||||
widget.settings
|
|
||||||
.val(write: true)
|
.val(write: true)
|
||||||
.devToolsSettings
|
.devToolsSettings
|
||||||
.showPerformanceOverlay = e!;
|
.showPerformanceOverlay = e,
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
SettingsCheckboxTile(
|
||||||
ListTile(
|
icon: Icons.screen_search_desktop_outlined,
|
||||||
leading: const Icon(
|
title: 'Indicate offscreen layers',
|
||||||
Icons.screen_search_desktop_outlined,
|
|
||||||
),
|
|
||||||
title: const Text('Indicate offscreen layers'),
|
|
||||||
trailing: Checkbox(
|
|
||||||
value: dev.checkerboardOffscreenLayers,
|
value: dev.checkerboardOffscreenLayers,
|
||||||
onChanged: (e) {
|
onChanged: (e) => widget.settings
|
||||||
Haptics.selection();
|
|
||||||
widget.settings
|
|
||||||
.val(write: true)
|
.val(write: true)
|
||||||
.devToolsSettings
|
.devToolsSettings
|
||||||
.checkerboardOffscreenLayers = e!;
|
.checkerboardOffscreenLayers = e,
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
SettingsCheckboxTile(
|
||||||
ListTile(
|
icon: Icons.imagesearch_roller_outlined,
|
||||||
leading: const Icon(Icons.imagesearch_roller_outlined),
|
title: 'Indicate raster cache images',
|
||||||
title: const Text('Indicate raster cache images'),
|
|
||||||
trailing: Checkbox(
|
|
||||||
value: dev.checkerboardRasterCacheImages,
|
value: dev.checkerboardRasterCacheImages,
|
||||||
onChanged: (e) {
|
onChanged: (e) => widget.settings
|
||||||
Haptics.selection();
|
|
||||||
widget.settings
|
|
||||||
.val(write: true)
|
.val(write: true)
|
||||||
.devToolsSettings
|
.devToolsSettings
|
||||||
.checkerboardRasterCacheImages = e!;
|
.checkerboardRasterCacheImages = e,
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../widgets/settings_checkbox_tile.dart';
|
||||||
|
|
||||||
class FilesSection extends StatelessWidget {
|
class FilesSection extends StatelessWidget {
|
||||||
const FilesSection({super.key});
|
const FilesSection({super.key});
|
||||||
@@ -12,30 +12,20 @@ class FilesSection extends StatelessWidget {
|
|||||||
final settings = context.watch<SettingsCubit>();
|
final settings = context.watch<SettingsCubit>();
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
SettingsCheckboxTile(
|
||||||
leading: const Icon(Icons.drive_folder_upload_outlined),
|
icon: Icons.drive_folder_upload_outlined,
|
||||||
title: const Text('Ordner in Dateien nach oben sortieren'),
|
title: 'Ordner in Dateien nach oben sortieren',
|
||||||
trailing: Checkbox(
|
|
||||||
value: settings.val().fileSettings.sortFoldersToTop,
|
value: settings.val().fileSettings.sortFoldersToTop,
|
||||||
onChanged: (e) {
|
onChanged: (e) =>
|
||||||
Haptics.selection();
|
settings.val(write: true).fileSettings.sortFoldersToTop = e,
|
||||||
settings.val(write: true).fileSettings.sortFoldersToTop = e!;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
SettingsCheckboxTile(
|
||||||
ListTile(
|
icon: Icons.open_in_new_outlined,
|
||||||
leading: const Icon(Icons.open_in_new_outlined),
|
title: 'Dateien immer mit Systemdialog öffnen',
|
||||||
title: const Text('Dateien immer mit Systemdialog öffnen'),
|
|
||||||
trailing: Checkbox(
|
|
||||||
value: settings.val().fileViewSettings.alwaysOpenExternally,
|
value: settings.val().fileViewSettings.alwaysOpenExternally,
|
||||||
onChanged: (e) {
|
onChanged: (e) =>
|
||||||
Haptics.selection();
|
settings.val(write: true).fileViewSettings.alwaysOpenExternally =
|
||||||
settings
|
e,
|
||||||
.val(write: true)
|
|
||||||
.fileViewSettings
|
|
||||||
.alwaysOpenExternally = e!;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import '../../../../push/push_registration.dart';
|
import '../../../../push/push_registration.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../widgets/push_status_sheet.dart';
|
import '../widgets/push_status_sheet.dart';
|
||||||
|
import '../widgets/settings_checkbox_tile.dart';
|
||||||
|
|
||||||
class TalkSection extends StatelessWidget {
|
class TalkSection extends StatelessWidget {
|
||||||
const TalkSection({super.key});
|
const TalkSection({super.key});
|
||||||
@@ -20,27 +20,19 @@ class TalkSection extends StatelessWidget {
|
|||||||
final notificationSettings = settings.val().notificationSettings;
|
final notificationSettings = settings.val().notificationSettings;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
SettingsCheckboxTile(
|
||||||
leading: const Icon(Icons.star_border),
|
icon: Icons.star_border,
|
||||||
title: const Text('Favoriten im Talk nach oben sortieren'),
|
title: 'Favoriten im Talk nach oben sortieren',
|
||||||
trailing: Checkbox(
|
|
||||||
value: talkSettings.sortFavoritesToTop,
|
value: talkSettings.sortFavoritesToTop,
|
||||||
onChanged: (e) {
|
onChanged: (e) =>
|
||||||
Haptics.selection();
|
settings.val(write: true).talkSettings.sortFavoritesToTop = e,
|
||||||
settings.val(write: true).talkSettings.sortFavoritesToTop = e!;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
SettingsCheckboxTile(
|
||||||
ListTile(
|
icon: Icons.mark_email_unread_outlined,
|
||||||
leading: const Icon(Icons.mark_email_unread_outlined),
|
title: 'Ungelesene Chats nach oben sortieren',
|
||||||
title: const Text('Ungelesene Chats nach oben sortieren'),
|
|
||||||
trailing: Checkbox(
|
|
||||||
value: talkSettings.sortUnreadToTop,
|
value: talkSettings.sortUnreadToTop,
|
||||||
onChanged: (e) {
|
onChanged: (e) =>
|
||||||
Haptics.selection();
|
settings.val(write: true).talkSettings.sortUnreadToTop = e,
|
||||||
settings.val(write: true).talkSettings.sortUnreadToTop = e!;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.wallpaper_outlined),
|
leading: const Icon(Icons.wallpaper_outlined),
|
||||||
@@ -49,19 +41,12 @@ class TalkSection extends StatelessWidget {
|
|||||||
trailing: const Icon(Icons.arrow_right),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () => AppRoutes.openChatBackgroundSettings(context),
|
onTap: () => AppRoutes.openChatBackgroundSettings(context),
|
||||||
),
|
),
|
||||||
ListTile(
|
SettingsCheckboxTile(
|
||||||
leading: const CenteredLeading(
|
icon: Icons.notifications_active_outlined,
|
||||||
Icon(Icons.notifications_active_outlined),
|
title: 'Push-Benachrichtigungen',
|
||||||
),
|
subtitle: 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten',
|
||||||
title: const Text('Push-Benachrichtigungen'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Benachrichtigungen bei neuen Talk-Nachrichten erhalten',
|
|
||||||
),
|
|
||||||
trailing: Checkbox(
|
|
||||||
value: notificationSettings.enabled,
|
value: notificationSettings.enabled,
|
||||||
onChanged: (e) {
|
onChanged: (enabled) {
|
||||||
Haptics.selection();
|
|
||||||
final enabled = e ?? false;
|
|
||||||
settings.val(write: true).notificationSettings.enabled = enabled;
|
settings.val(write: true).notificationSettings.enabled = enabled;
|
||||||
// Turning off does NOT unregister: the device stays subscribed so
|
// Turning off does NOT unregister: the device stays subscribed so
|
||||||
// silent sync pushes keep arriving; the message handler and iOS
|
// silent sync pushes keep arriving; the message handler and iOS
|
||||||
@@ -89,7 +74,6 @@ class TalkSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)),
|
leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)),
|
||||||
title: const Text('Push-Status'),
|
title: const Text('Push-Status'),
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
|
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
|
||||||
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
||||||
|
import '../widgets/settings_checkbox_tile.dart';
|
||||||
|
import '../widgets/settings_dropdown_tile.dart';
|
||||||
|
|
||||||
class TimetableSection extends StatelessWidget {
|
class TimetableSection extends StatelessWidget {
|
||||||
const TimetableSection({super.key});
|
const TimetableSection({super.key});
|
||||||
@@ -15,47 +16,23 @@ class TimetableSection extends StatelessWidget {
|
|||||||
final timetableSettings = settings.val().timetableSettings;
|
final timetableSettings = settings.val().timetableSettings;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
SettingsDropdownTile<TimetableNameMode>(
|
||||||
leading: const Icon(Icons.abc_outlined),
|
icon: Icons.abc_outlined,
|
||||||
title: const Text('Fachbezeichnung'),
|
title: 'Fachbezeichnung',
|
||||||
trailing: DropdownButton<TimetableNameMode>(
|
|
||||||
value: timetableSettings.timetableNameMode,
|
value: timetableSettings.timetableNameMode,
|
||||||
icon: const Icon(Icons.arrow_drop_down),
|
options: TimetableNameMode.values,
|
||||||
items: TimetableNameMode.values
|
optionIcon: (e) => TimetableNameModes.getDisplayOptions(e).icon,
|
||||||
.map(
|
optionLabel: (e) => TimetableNameModes.getDisplayOptions(e).displayName,
|
||||||
(e) => DropdownMenuItem(
|
onChanged: (e) =>
|
||||||
value: e,
|
settings.val(write: true).timetableSettings.timetableNameMode = e,
|
||||||
enabled: e != timetableSettings.timetableNameMode,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(TimetableNameModes.getDisplayOptions(e).icon),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
TimetableNameModes.getDisplayOptions(e).displayName,
|
|
||||||
),
|
),
|
||||||
],
|
SettingsCheckboxTile(
|
||||||
),
|
icon: Icons.calendar_view_day_outlined,
|
||||||
),
|
title: 'Doppelstunden zusammenhängend anzeigen',
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (value) =>
|
|
||||||
settings.val(write: true).timetableSettings.timetableNameMode =
|
|
||||||
value!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.calendar_view_day_outlined),
|
|
||||||
title: const Text('Doppelstunden zusammenhängend anzeigen'),
|
|
||||||
trailing: Checkbox(
|
|
||||||
value: timetableSettings.connectDoubleLessons,
|
value: timetableSettings.connectDoubleLessons,
|
||||||
onChanged: (e) {
|
onChanged: (e) =>
|
||||||
Haptics.selection();
|
settings.val(write: true).timetableSettings.connectDoubleLessons =
|
||||||
settings
|
e,
|
||||||
.val(write: true)
|
|
||||||
.timetableSettings
|
|
||||||
.connectDoubleLessons = e!;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.palette_outlined),
|
leading: const Icon(Icons.palette_outlined),
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../utils/haptics.dart';
|
||||||
|
import '../../../../widget/centered_leading.dart';
|
||||||
|
|
||||||
|
/// Settings row with a trailing checkbox. Fires [Haptics.selection] before
|
||||||
|
/// invoking [onChanged] (with the resolved non-null value), so the sections
|
||||||
|
/// don't repeat that. The leading icon is vertically centered when a [subtitle]
|
||||||
|
/// is present, matching the surrounding settings styling.
|
||||||
|
class SettingsCheckboxTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool> onChanged;
|
||||||
|
|
||||||
|
const SettingsCheckboxTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.subtitle,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final leadingIcon = Icon(icon);
|
||||||
|
return ListTile(
|
||||||
|
leading: subtitle == null ? leadingIcon : CenteredLeading(leadingIcon),
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: subtitle == null ? null : Text(subtitle!),
|
||||||
|
trailing: Checkbox(
|
||||||
|
value: value,
|
||||||
|
onChanged: (e) {
|
||||||
|
Haptics.selection();
|
||||||
|
onChanged(e ?? false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Settings row with a trailing [DropdownButton]. Each option renders as an
|
||||||
|
/// icon + label row; the currently selected option is disabled in the menu,
|
||||||
|
/// matching the settings styling. [onChanged] receives the picked (non-null)
|
||||||
|
/// value.
|
||||||
|
class SettingsDropdownTile<T> extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final T value;
|
||||||
|
final List<T> options;
|
||||||
|
final IconData Function(T) optionIcon;
|
||||||
|
final String Function(T) optionLabel;
|
||||||
|
final ValueChanged<T> onChanged;
|
||||||
|
|
||||||
|
const SettingsDropdownTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.options,
|
||||||
|
required this.optionIcon,
|
||||||
|
required this.optionLabel,
|
||||||
|
required this.onChanged,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon),
|
||||||
|
title: Text(title),
|
||||||
|
trailing: DropdownButton<T>(
|
||||||
|
value: value,
|
||||||
|
icon: const Icon(Icons.arrow_drop_down),
|
||||||
|
items: options
|
||||||
|
.map(
|
||||||
|
(e) => DropdownMenuItem<T>(
|
||||||
|
value: e,
|
||||||
|
enabled: e != value,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(optionIcon(e)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(optionLabel(e)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (e) {
|
||||||
|
if (e != null) onChanged(e);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
|
|||||||
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
|
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import '../../../widget/app_progress_indicator.dart';
|
||||||
import '../../../widget/info_dialog.dart';
|
import '../../../widget/info_dialog.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import '../files/files_upload_dialog.dart';
|
import '../files/files_upload_dialog.dart';
|
||||||
@@ -275,6 +276,6 @@ Future<void> _showBlockingSpinner(BuildContext context) => showDialog<void>(
|
|||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (_) => const PopScope(
|
builder: (_) => const PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: AppProgressIndicator.large()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import '../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
|
import '../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
|
||||||
import '../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
|
import '../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
|
||||||
@@ -21,6 +20,7 @@ import '../../../widget/prosemirror/pm_json_view.dart';
|
|||||||
import 'widgets/ticker_content_card.dart';
|
import 'widgets/ticker_content_card.dart';
|
||||||
import 'widgets/ticker_nav_list.dart';
|
import 'widgets/ticker_nav_list.dart';
|
||||||
import 'widgets/ticker_page_body.dart';
|
import 'widgets/ticker_page_body.dart';
|
||||||
|
import 'widgets/ticker_updated_bar.dart';
|
||||||
|
|
||||||
/// Ticker module entry. Wires the [TickerBloc] to the presentation
|
/// Ticker module entry. Wires the [TickerBloc] to the presentation
|
||||||
/// [TickerScaffold]; the "Aktuelles" home surface is driven through the loadable
|
/// [TickerScaffold]; the "Aktuelles" home surface is driven through the loadable
|
||||||
@@ -46,8 +46,6 @@ class TickerView extends StatelessWidget {
|
|||||||
onSelectionChanged: (slug) => bloc.add(
|
onSelectionChanged: (slug) => bloc.add(
|
||||||
Emit<TickerState>((state) => state.copyWith(selectedSlug: slug)),
|
Emit<TickerState>((state) => state.copyWith(selectedSlug: slug)),
|
||||||
),
|
),
|
||||||
homePublishedAt: data?.ticker?.publishedAt,
|
|
||||||
onRefreshHome: bloc.retry,
|
|
||||||
homeBuilder: (context, onLinkTap) =>
|
homeBuilder: (context, onLinkTap) =>
|
||||||
LoadableStateConsumer<TickerBloc, TickerState>(
|
LoadableStateConsumer<TickerBloc, TickerState>(
|
||||||
child: (state, loading) => _TickerHome(
|
child: (state, loading) => _TickerHome(
|
||||||
@@ -93,14 +91,6 @@ class TickerScaffold extends StatefulWidget {
|
|||||||
/// it (null = home). Also fired when a stale [initialSlug] is discarded.
|
/// it (null = home). Also fired when a stale [initialSlug] is discarded.
|
||||||
final void Function(String? slug)? onSelectionChanged;
|
final void Function(String? slug)? onSelectionChanged;
|
||||||
|
|
||||||
/// ISO publish timestamp of the current "Aktuelles" post, shown as a dated
|
|
||||||
/// refresh button in the app bar while the home surface is open. Null hides
|
|
||||||
/// the button (no post / no date / on a page, which carries no date).
|
|
||||||
final String? homePublishedAt;
|
|
||||||
|
|
||||||
/// Tapped from the app bar's dated button to reload the ticker.
|
|
||||||
final VoidCallback? onRefreshHome;
|
|
||||||
|
|
||||||
const TickerScaffold({
|
const TickerScaffold({
|
||||||
super.key,
|
super.key,
|
||||||
required this.sections,
|
required this.sections,
|
||||||
@@ -108,8 +98,6 @@ class TickerScaffold extends StatefulWidget {
|
|||||||
this.pageBuilder,
|
this.pageBuilder,
|
||||||
this.initialSlug,
|
this.initialSlug,
|
||||||
this.onSelectionChanged,
|
this.onSelectionChanged,
|
||||||
this.homePublishedAt,
|
|
||||||
this.onRefreshHome,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static const double sidebarBreakpoint = 900;
|
static const double sidebarBreakpoint = 900;
|
||||||
@@ -326,22 +314,6 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
|
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
|
||||||
actions: [
|
actions: [
|
||||||
// The "Aktuelles" post's date, shown only on the home surface
|
|
||||||
// (pages carry no date). Tapping reloads the ticker.
|
|
||||||
if (slug == null)
|
|
||||||
Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final updatedAt = _formatPublishedAt(
|
|
||||||
context,
|
|
||||||
widget.homePublishedAt,
|
|
||||||
);
|
|
||||||
if (updatedAt == null) return const SizedBox.shrink();
|
|
||||||
return _UpdatedAtButton(
|
|
||||||
text: updatedAt,
|
|
||||||
onTap: widget.onRefreshHome,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Always present so "Aktuelles" is a fixed anchor; greyed out
|
// Always present so "Aktuelles" is a fixed anchor; greyed out
|
||||||
// (disabled) while it is the current surface.
|
// (disabled) while it is the current surface.
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -415,8 +387,12 @@ class _TickerHome extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||||
children: [_CurrentTickerCard(ticker: ticker, onLinkTap: onLinkTap)],
|
children: [
|
||||||
|
TickerUpdatedBar(publishedAt: ticker.publishedAt),
|
||||||
|
_CurrentTickerCard(ticker: ticker, onLinkTap: onLinkTap),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,46 +416,6 @@ class _CurrentTickerCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App bar button showing when the "Aktuelles" post was last updated; tapping
|
|
||||||
/// reloads the ticker. Styled to sit on the app bar (onSurface foreground) with
|
|
||||||
/// a clock icon and the full date in a tooltip.
|
|
||||||
class _UpdatedAtButton extends StatelessWidget {
|
|
||||||
final String text;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const _UpdatedAtButton({required this.text, this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
|
||||||
child: Tooltip(
|
|
||||||
message: 'Aktualisiert am $text',
|
|
||||||
child: TextButton.icon(
|
|
||||||
onPressed: onTap,
|
|
||||||
icon: const Icon(Icons.schedule, size: 16),
|
|
||||||
label: Text(text),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: theme.colorScheme.onSurface,
|
|
||||||
textStyle: theme.textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formats the ISO `publishedAt` as `dd.MM.yyyy, HH:mm` in the device locale, or
|
|
||||||
/// null when it is missing/unparseable so callers can drop the line entirely.
|
|
||||||
String? _formatPublishedAt(BuildContext context, String? iso) {
|
|
||||||
if (iso == null || iso.isEmpty) return null;
|
|
||||||
final parsed = DateTime.tryParse(iso);
|
|
||||||
if (parsed == null) return null;
|
|
||||||
final locale = Localizations.localeOf(context).toString();
|
|
||||||
return DateFormat('dd.MM.yyyy, HH:mm', locale).format(parsed.toLocal());
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UnavailableHint extends StatelessWidget {
|
class _UnavailableHint extends StatelessWidget {
|
||||||
final String webUrl;
|
final String webUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
|
||||||
import '../../../../api/errors/error_mapper.dart';
|
import '../../../../api/errors/error_mapper.dart';
|
||||||
import '../../../../api/errors/ticker_content_unavailable_exception.dart';
|
|
||||||
import '../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
import '../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/ticker/repository/ticker_repository.dart';
|
import '../../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
||||||
|
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
|
import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||||
|
import '../../../../state/app/modules/ticker/bloc/ticker_page_bloc.dart';
|
||||||
|
import '../../../../state/app/modules/ticker/bloc/ticker_page_state.dart';
|
||||||
|
import '../../../../state/app/modules/ticker/repository/ticker_page_repository.dart';
|
||||||
import '../../../../theming/app_theme.dart';
|
import '../../../../theming/app_theme.dart';
|
||||||
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
import '../../../../widget/placeholder_view.dart';
|
import '../../../../widget/placeholder_view.dart';
|
||||||
import '../../../../widget/prosemirror/pm_json_view.dart';
|
import '../../../../widget/prosemirror/pm_json_view.dart';
|
||||||
import 'ticker_content_card.dart';
|
import 'ticker_content_card.dart';
|
||||||
|
import 'ticker_updated_bar.dart';
|
||||||
|
|
||||||
/// Embeddable renderer for a single ticker page. Loads the page on demand and
|
/// Embeddable renderer for a single ticker page: drives a per-slug
|
||||||
/// renders it by kind: CONTENT via the native ProseMirror renderer,
|
/// [TickerPageBloc] through [LoadableStateConsumer], so pages behave like the
|
||||||
/// PROXIED_FILE as a PDF. It carries no Scaffold/AppBar so it can live both
|
/// home surface (cache, background refresh, offline banner, pull-to-refresh).
|
||||||
/// in-place inside [TickerView] and inside the standalone `TickerPageView`
|
/// Carries no Scaffold so it works both in-place in [TickerView] and in the
|
||||||
/// (deep links from outside the ticker module).
|
/// standalone `TickerPageView`. A REDIRECT opens the browser and invokes
|
||||||
///
|
/// [onRedirect] so the host can leave this page.
|
||||||
/// Reaching a REDIRECT here (e.g. via an internal content link whose slug turns
|
class TickerPageBody extends StatelessWidget {
|
||||||
/// out to be a redirect) opens the browser and, if given, invokes [onRedirect]
|
|
||||||
/// so the host can leave this page.
|
|
||||||
class TickerPageBody extends StatefulWidget {
|
|
||||||
final String slug;
|
final String slug;
|
||||||
final void Function(String href) onLinkTap;
|
final void Function(String href) onLinkTap;
|
||||||
final VoidCallback? onRedirect;
|
final VoidCallback? onRedirect;
|
||||||
@@ -35,75 +39,72 @@ class TickerPageBody extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TickerPageBody> createState() => _TickerPageBodyState();
|
Widget build(BuildContext context) =>
|
||||||
}
|
BlocModule<TickerPageBloc, LoadableState<TickerPageState>>(
|
||||||
|
// A slug switch must rebuild the provider with a fresh bloc.
|
||||||
class _TickerPageBodyState extends State<TickerPageBody> {
|
key: ValueKey(slug),
|
||||||
final TickerRepository _repo = TickerRepository();
|
create: (context) => TickerPageBloc(slug),
|
||||||
late Future<TickerPageResponse> _future;
|
child: (context, bloc, _) =>
|
||||||
|
LoadableStateConsumer<TickerPageBloc, TickerPageState>(
|
||||||
@override
|
isReady: (state) => state.page != null,
|
||||||
void initState() {
|
child: (state, loading) => _TickerPageContent(
|
||||||
super.initState();
|
page: state.page!,
|
||||||
_future = _repo.getPage(widget.slug);
|
onLinkTap: onLinkTap,
|
||||||
}
|
onRedirect: onRedirect,
|
||||||
|
),
|
||||||
void _reload() {
|
|
||||||
setState(() => _future = _repo.getPage(widget.slug));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => FutureBuilder<TickerPageResponse>(
|
|
||||||
future: _future,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
final error = snapshot.error;
|
|
||||||
if (error != null) return _buildError(context, error);
|
|
||||||
return _buildContent(context, snapshot.data!);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _buildError(BuildContext context, Object error) {
|
|
||||||
if (error is TickerContentUnavailableException) {
|
|
||||||
return PlaceholderView(
|
|
||||||
icon: Icons.public_off_outlined,
|
|
||||||
text: error.userMessage,
|
|
||||||
button: error.webUrl == null
|
|
||||||
? null
|
|
||||||
: ElevatedButton.icon(
|
|
||||||
onPressed: () => AppRoutes.openWebUrl(error.webUrl!),
|
|
||||||
icon: const Icon(Icons.open_in_new),
|
|
||||||
label: const Text('Im Browser öffnen'),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return PlaceholderView(
|
|
||||||
icon: Icons.error_outline,
|
|
||||||
text: errorToUserMessage(error),
|
|
||||||
button: errorAllowsRetry(error)
|
|
||||||
? ElevatedButton.icon(
|
|
||||||
onPressed: _reload,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('Erneut versuchen'),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContent(BuildContext context, TickerPageResponse page) {
|
/// Renders a resolved [TickerPageResponse] by kind. Stateful so a REDIRECT
|
||||||
|
/// fires only once across the consumer's background-refresh rebuilds.
|
||||||
|
class _TickerPageContent extends StatefulWidget {
|
||||||
|
final TickerPageResponse page;
|
||||||
|
final void Function(String href) onLinkTap;
|
||||||
|
final VoidCallback? onRedirect;
|
||||||
|
|
||||||
|
const _TickerPageContent({
|
||||||
|
required this.page,
|
||||||
|
required this.onLinkTap,
|
||||||
|
this.onRedirect,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TickerPageContent> createState() => _TickerPageContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TickerPageContentState extends State<_TickerPageContent> {
|
||||||
|
bool _redirected = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final page = widget.page;
|
||||||
switch (page.kind) {
|
switch (page.kind) {
|
||||||
case TickerPageKind.redirect:
|
case TickerPageKind.redirect:
|
||||||
final url = page.externalUrl;
|
final url = page.externalUrl;
|
||||||
|
if (!_redirected) {
|
||||||
|
_redirected = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url);
|
if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url);
|
||||||
widget.onRedirect?.call();
|
widget.onRedirect?.call();
|
||||||
});
|
});
|
||||||
return const Center(child: CircularProgressIndicator());
|
}
|
||||||
|
return const Center(child: AppProgressIndicator.large());
|
||||||
case TickerPageKind.proxiedFile:
|
case TickerPageKind.proxiedFile:
|
||||||
return _ProxiedFileView(repo: _repo, slug: widget.slug);
|
return Column(
|
||||||
|
children: [
|
||||||
|
// For proxied files the data currency is the last successful
|
||||||
|
// proxy fetch, not the page's publish date.
|
||||||
|
TickerUpdatedBar(publishedAt: page.fileFetchedAt ?? page.publishedAt),
|
||||||
|
Expanded(
|
||||||
|
child: _ProxiedFileView(
|
||||||
|
repo: context.read<TickerPageBloc>().repo,
|
||||||
|
slug: page.slug ?? context.read<TickerPageBloc>().slug,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
final content = page.content;
|
final content = page.content;
|
||||||
if (content == null) {
|
if (content == null) {
|
||||||
@@ -120,17 +121,25 @@ class _TickerPageBodyState extends State<TickerPageBody> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
// Pull-to-refresh must trigger even when content fits the viewport.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||||
child: TickerContentCard(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TickerUpdatedBar(publishedAt: page.publishedAt),
|
||||||
|
TickerContentCard(
|
||||||
child: PmJsonView(json: content, onLinkTap: widget.onLinkTap),
|
child: PmJsonView(json: content, onLinkTap: widget.onLinkTap),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProxiedFileView extends StatefulWidget {
|
class _ProxiedFileView extends StatefulWidget {
|
||||||
final TickerRepository repo;
|
final TickerPageRepository repo;
|
||||||
final String slug;
|
final String slug;
|
||||||
|
|
||||||
const _ProxiedFileView({required this.repo, required this.slug});
|
const _ProxiedFileView({required this.repo, required this.slug});
|
||||||
@@ -153,7 +162,7 @@ class _ProxiedFileViewState extends State<_ProxiedFileView> {
|
|||||||
future: _bytes,
|
future: _bytes,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: AppProgressIndicator.large());
|
||||||
}
|
}
|
||||||
final error = snapshot.error;
|
final error = snapshot.error;
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
@@ -162,9 +171,9 @@ class _ProxiedFileViewState extends State<_ProxiedFileView> {
|
|||||||
text: errorToUserMessage(error),
|
text: errorToUserMessage(error),
|
||||||
button: errorAllowsRetry(error)
|
button: errorAllowsRetry(error)
|
||||||
? ElevatedButton.icon(
|
? ElevatedButton.icon(
|
||||||
onPressed: () => setState(
|
onPressed: () => setState(() {
|
||||||
() => _bytes = widget.repo.getPageFile(widget.slug),
|
_bytes = widget.repo.getPageFile(widget.slug);
|
||||||
),
|
}),
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Erneut versuchen'),
|
label: const Text('Erneut versuchen'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../theming/app_theme.dart';
|
||||||
|
|
||||||
|
/// Non-interactive "Aktualisiert am …" mini heading above the content card
|
||||||
|
/// (refresh is pull-to-refresh). Left-aligned with the card's content indent.
|
||||||
|
/// Renders nothing when [publishedAt] is missing/unparseable.
|
||||||
|
class TickerUpdatedBar extends StatelessWidget {
|
||||||
|
final String? publishedAt;
|
||||||
|
|
||||||
|
const TickerUpdatedBar({super.key, this.publishedAt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final iso = publishedAt;
|
||||||
|
final parsed = iso == null || iso.isEmpty ? null : DateTime.tryParse(iso);
|
||||||
|
if (parsed == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final muted = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.sm + AppSpacing.md,
|
||||||
|
AppSpacing.sm,
|
||||||
|
AppSpacing.md,
|
||||||
|
AppSpacing.xs,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.schedule, size: 13, color: muted),
|
||||||
|
const SizedBox(width: AppSpacing.xs),
|
||||||
|
Text(
|
||||||
|
'Aktualisiert am ${parsed.toLocal().formatDateTime()}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(color: muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class About extends StatelessWidget {
|
|
||||||
const About({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Über diese App')),
|
|
||||||
body: const Card(
|
|
||||||
elevation: 1,
|
|
||||||
borderOnForeground: true,
|
|
||||||
child: Text('Marianum Fulda'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
import 'avatar_crop_page.dart';
|
import '../routing/app_routes.dart';
|
||||||
import 'file_pick.dart';
|
import 'file_pick.dart';
|
||||||
|
|
||||||
/// Result of the user's choice inside [showAvatarActionsSheet]. The sheet
|
/// Result of the user's choice inside [showAvatarActionsSheet]. The sheet
|
||||||
@@ -107,10 +107,5 @@ Future<Uint8List?> _pickAndCrop(
|
|||||||
if (picked == null) return null;
|
if (picked == null) return null;
|
||||||
final bytes = await picked.readAsBytes();
|
final bytes = await picked.readAsBytes();
|
||||||
if (!context.mounted) return null;
|
if (!context.mounted) return null;
|
||||||
return Navigator.of(context).push<Uint8List>(
|
return AppRoutes.openAvatarCrop(context, imageBytes: bytes);
|
||||||
MaterialPageRoute(
|
|
||||||
fullscreenDialog: true,
|
|
||||||
builder: (_) => AvatarCropPage(imageBytes: bytes),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Persistent disk cache for the in-app [UserAvatar] widget.
|
||||||
|
///
|
||||||
|
/// The widget keeps a session-scoped in-memory LRU; this store survives app
|
||||||
|
/// restarts so a cold start paints the last-known picture on the first frame
|
||||||
|
/// instead of a blank placeholder while the network request is in flight.
|
||||||
|
/// Bytes are stored raw (PNG/JPEG/WEBP/SVG) — the widget re-detects SVG from
|
||||||
|
/// the bytes on read, so no content-type sidecar is needed.
|
||||||
|
///
|
||||||
|
/// Distinct from [PushAvatarStore], which caches PRE-MASKED round PNGs for the
|
||||||
|
/// FCM background isolate and only handles room avatars.
|
||||||
|
class AvatarDiskCache {
|
||||||
|
AvatarDiskCache._();
|
||||||
|
static final AvatarDiskCache instance = AvatarDiskCache._();
|
||||||
|
|
||||||
|
static const _subDirectory = 'avatar_cache';
|
||||||
|
|
||||||
|
/// Files older than this are pruned. Only bounds disk for subjects that are
|
||||||
|
/// no longer seen — freshness within a session is handled by the widget's
|
||||||
|
/// background refresh, which always re-fetches over the network.
|
||||||
|
static const Duration maxAge = Duration(days: 30);
|
||||||
|
|
||||||
|
// Memoized so the async directory lookup runs once, and its resolved path is
|
||||||
|
// exposed for the synchronous read path (zero-flash on warm sessions).
|
||||||
|
Future<Directory>? _dirFuture;
|
||||||
|
static String? _dirPath;
|
||||||
|
|
||||||
|
// Prune runs once per session after the first successful write, so a
|
||||||
|
// cold-start burst of avatar fetches doesn't re-list the directory per file.
|
||||||
|
bool _pruned = false;
|
||||||
|
|
||||||
|
/// Kicks off directory resolution so [readSync] can hit on the very first
|
||||||
|
/// avatar of a session. Fire-and-forget from app start; safe to call twice.
|
||||||
|
void warmUp() => unawaited(_directory());
|
||||||
|
|
||||||
|
Future<Directory> _directory() {
|
||||||
|
return _dirFuture ??= _resolveDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _resolveDirectory() async {
|
||||||
|
final base = await getApplicationCacheDirectory();
|
||||||
|
final dir = Directory('${base.path}/$_subDirectory');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
_dirPath = dir.path;
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File-safe, prefix-evictable name. The subject id is hex-encoded so it can
|
||||||
|
/// only contain `[0-9a-f]`, which keeps the `_` separator unambiguous: the
|
||||||
|
/// user prefix `u_<hex>_` never matches a longer id's file.
|
||||||
|
static String fileName({
|
||||||
|
required String id,
|
||||||
|
required bool isGroup,
|
||||||
|
required int size,
|
||||||
|
}) {
|
||||||
|
final hex = _hex(id);
|
||||||
|
// Group avatars are served at one fixed size (no size in the URL).
|
||||||
|
return isGroup ? 'g_$hex' : 'u_${hex}_$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _hex(String value) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (final b in utf8.encode(value)) {
|
||||||
|
buffer.write(b.toRadixString(16).padLeft(2, '0'));
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous read for warm sessions (cache directory already resolved).
|
||||||
|
/// Returns null when the directory isn't known yet — the caller falls back
|
||||||
|
/// to [read]. Returns null on any error so a corrupt file never throws into
|
||||||
|
/// a build.
|
||||||
|
Uint8List? readSync({
|
||||||
|
required String id,
|
||||||
|
required bool isGroup,
|
||||||
|
required int size,
|
||||||
|
}) {
|
||||||
|
final path = _dirPath;
|
||||||
|
if (path == null) return null;
|
||||||
|
try {
|
||||||
|
final file = File(
|
||||||
|
'$path/${fileName(id: id, isGroup: isGroup, size: size)}',
|
||||||
|
);
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final bytes = file.readAsBytesSync();
|
||||||
|
return bytes.isEmpty ? null : bytes;
|
||||||
|
} on Object {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List?> read({
|
||||||
|
required String id,
|
||||||
|
required bool isGroup,
|
||||||
|
required int size,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final dir = await _directory();
|
||||||
|
final file = File(
|
||||||
|
'${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}',
|
||||||
|
);
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
return bytes.isEmpty ? null : bytes;
|
||||||
|
} on Object {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write({
|
||||||
|
required String id,
|
||||||
|
required bool isGroup,
|
||||||
|
required int size,
|
||||||
|
required Uint8List bytes,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final dir = await _directory();
|
||||||
|
final file = File(
|
||||||
|
'${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}',
|
||||||
|
);
|
||||||
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
|
if (!_pruned) {
|
||||||
|
_pruned = true;
|
||||||
|
unawaited(_prune(dir));
|
||||||
|
}
|
||||||
|
} on Object {
|
||||||
|
// Best effort — a failed write just means the next launch re-fetches.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops every cached size for a user (or the single file for a group).
|
||||||
|
/// Called from `invalidateAvatarCache` after an upload/removal or a 404.
|
||||||
|
Future<void> evict({required String id, required bool isGroup}) async {
|
||||||
|
try {
|
||||||
|
final dir = await _directory();
|
||||||
|
if (isGroup) {
|
||||||
|
final file = File('${dir.path}/${fileName(id: id, isGroup: true, size: 0)}');
|
||||||
|
if (file.existsSync()) await file.delete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final prefix = 'u_${_hex(id)}_';
|
||||||
|
await for (final entry in dir.list()) {
|
||||||
|
if (entry is! File) continue;
|
||||||
|
if (entry.uri.pathSegments.last.startsWith(prefix)) {
|
||||||
|
await entry.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} on Object {
|
||||||
|
// Best effort — the 30-day max age catches stragglers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipes the whole cache — used by the argument-less `invalidateAvatarCache`
|
||||||
|
/// (e.g. on logout).
|
||||||
|
Future<void> clear() async {
|
||||||
|
try {
|
||||||
|
final dir = await _directory();
|
||||||
|
if (dir.existsSync()) {
|
||||||
|
await for (final entry in dir.list()) {
|
||||||
|
if (entry is File) await entry.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} on Object {
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _prune(Directory dir) async {
|
||||||
|
try {
|
||||||
|
final now = DateTime.now();
|
||||||
|
await for (final entry in dir.list()) {
|
||||||
|
if (entry is! File) continue;
|
||||||
|
if (now.difference(entry.lastModifiedSync()) > maxAge) {
|
||||||
|
await entry.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} on Object {
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
import 'avatar_crop_page.dart';
|
import '../routing/app_routes.dart';
|
||||||
import 'file_pick.dart';
|
import 'file_pick.dart';
|
||||||
|
|
||||||
/// Bottom sheet with "from gallery" and "take photo" actions for choosing a
|
/// Bottom sheet with "from gallery" and "take photo" actions for choosing a
|
||||||
@@ -61,12 +61,7 @@ Future<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async {
|
|||||||
Future<Uint8List?> cropChatBackgroundImage(
|
Future<Uint8List?> cropChatBackgroundImage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Uint8List bytes,
|
Uint8List bytes,
|
||||||
) => Navigator.of(context).push<Uint8List>(
|
) => AppRoutes.openAvatarCrop(context, imageBytes: bytes, aspectRatio: null);
|
||||||
MaterialPageRoute(
|
|
||||||
fullscreenDialog: true,
|
|
||||||
builder: (_) => AvatarCropPage(imageBytes: bytes, aspectRatio: null),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<Uint8List?> _pickRaw(Future<XFile?> Function() pick) async {
|
Future<Uint8List?> _pickRaw(Future<XFile?> Function() pick) async {
|
||||||
final picked = await pick();
|
final picked = await pick();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:localstore/localstore.dart';
|
|||||||
|
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import '../../api/request_cache.dart';
|
import '../../api/request_cache.dart';
|
||||||
|
import '../app_progress_indicator.dart';
|
||||||
import 'json_viewer.dart';
|
import 'json_viewer.dart';
|
||||||
|
|
||||||
class CacheView extends StatefulWidget {
|
class CacheView extends StatefulWidget {
|
||||||
@@ -71,7 +72,7 @@ class _CacheViewState extends State<CacheView> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (snapshot.connectionState != ConnectionState.done) {
|
} else if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: AppProgressIndicator.large());
|
||||||
} else {
|
} else {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: PlaceholderView(
|
child: PlaceholderView(
|
||||||
|
|||||||
+20
-479
@@ -3,8 +3,6 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:chewie/chewie.dart';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -12,34 +10,20 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
import '../model/account_data.dart';
|
|
||||||
import '../model/endpoint_data.dart';
|
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
import '../share_intent/remote_file_ref.dart';
|
import '../share_intent/remote_file_ref.dart';
|
||||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import 'app_progress_indicator.dart';
|
import 'app_progress_indicator.dart';
|
||||||
import 'centered_leading.dart';
|
import 'centered_leading.dart';
|
||||||
|
import 'file_viewer/code_line.dart';
|
||||||
|
import 'file_viewer/deferred_pdf_viewer.dart';
|
||||||
|
import 'file_viewer/file_kind.dart';
|
||||||
|
import 'file_viewer/media_player.dart';
|
||||||
|
import 'file_viewer/unknown_preview_block.dart';
|
||||||
import 'info_dialog.dart';
|
import 'info_dialog.dart';
|
||||||
import 'share_position_origin.dart';
|
import 'share_position_origin.dart';
|
||||||
|
|
||||||
/// Nextcloud's `/index.php/core/preview` endpoint — returns a rasterized
|
|
||||||
/// thumbnail for any file the server has a preview provider for (images,
|
|
||||||
/// PDFs with the right backend, Office in some setups). Falls back to an
|
|
||||||
/// HTTP 404 when no preview is available, which lets [CachedNetworkImage]
|
|
||||||
/// trigger its `errorWidget`. Prefers `fileId` because the path variant
|
|
||||||
/// is unreliable on some server configurations.
|
|
||||||
String _ncPreviewUrl(RemoteFileRef remote, {int width = 1024}) {
|
|
||||||
final host = EndpointData().nextcloud().full();
|
|
||||||
final id = remote.fileId;
|
|
||||||
final selector = id != null
|
|
||||||
? 'fileId=$id'
|
|
||||||
: 'file=${Uri.encodeQueryComponent(remote.path)}';
|
|
||||||
return 'https://$host/index.php/core/preview?$selector&x=$width&y=-1&a=1';
|
|
||||||
}
|
|
||||||
|
|
||||||
class FileViewer extends StatefulWidget {
|
class FileViewer extends StatefulWidget {
|
||||||
final String path;
|
final String path;
|
||||||
final bool openExternal;
|
final bool openExternal;
|
||||||
@@ -61,133 +45,12 @@ class FileViewer extends StatefulWidget {
|
|||||||
|
|
||||||
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
||||||
|
|
||||||
enum _FileKind { image, svg, pdf, text, video, audio, unknown }
|
|
||||||
|
|
||||||
const Set<String> _imageExtensions = {
|
|
||||||
'png',
|
|
||||||
'jpg',
|
|
||||||
'jpeg',
|
|
||||||
'webp',
|
|
||||||
'gif',
|
|
||||||
'bmp',
|
|
||||||
'wbmp',
|
|
||||||
};
|
|
||||||
|
|
||||||
const Set<String> _videoExtensions = {
|
|
||||||
'mp4',
|
|
||||||
'm4v',
|
|
||||||
'mov',
|
|
||||||
'webm',
|
|
||||||
'mkv',
|
|
||||||
'3gp',
|
|
||||||
};
|
|
||||||
|
|
||||||
/// ogg/opus/flac are Android-only; iOS init errors fall through to the
|
|
||||||
/// "format not supported" message.
|
|
||||||
const Set<String> _audioExtensions = {
|
|
||||||
'mp3',
|
|
||||||
'm4a',
|
|
||||||
'aac',
|
|
||||||
'wav',
|
|
||||||
'flac',
|
|
||||||
'ogg',
|
|
||||||
'oga',
|
|
||||||
'opus',
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Unknown extensions still get a content sniff via [_looksLikeText].
|
|
||||||
const Set<String> _textExtensions = {
|
|
||||||
'txt', 'md', 'markdown', 'rst', 'log',
|
|
||||||
'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
|
|
||||||
'csv', 'tsv', 'tab',
|
|
||||||
'ini', 'conf', 'cfg', 'env', 'properties',
|
|
||||||
'html', 'htm', 'xhtml',
|
|
||||||
'css', 'scss', 'sass', 'less',
|
|
||||||
'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx',
|
|
||||||
'dart', 'java', 'kt', 'kts', 'groovy', 'scala', 'swift',
|
|
||||||
'py', 'rb', 'pl', 'lua', 'r',
|
|
||||||
'go', 'rs', 'zig',
|
|
||||||
'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'm', 'mm',
|
|
||||||
'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
|
||||||
'sql', 'graphql', 'gql',
|
|
||||||
'gitignore', 'gitattributes', 'editorconfig', 'dockerignore',
|
|
||||||
'dockerfile', 'makefile', 'cmake',
|
|
||||||
'tex', 'bib',
|
|
||||||
'srt', 'vtt',
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify.
|
|
||||||
Future<bool> _looksLikeText(String path) async {
|
|
||||||
final file = File(path);
|
|
||||||
RandomAccessFile? raf;
|
|
||||||
try {
|
|
||||||
final length = await file.length();
|
|
||||||
if (length == 0) return true;
|
|
||||||
raf = await file.open();
|
|
||||||
final sample = await raf.read(min(length, 8192));
|
|
||||||
if (sample.contains(0)) return false;
|
|
||||||
utf8.decode(sample);
|
|
||||||
return true;
|
|
||||||
} on Object {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
await raf?.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
|
|
||||||
/// animation. Defer until the route enter animation completes.
|
|
||||||
class _DeferredPdfViewer extends StatefulWidget {
|
|
||||||
const _DeferredPdfViewer({required this.path});
|
|
||||||
final String path;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_DeferredPdfViewer> createState() => _DeferredPdfViewerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
|
|
||||||
bool _ready = false;
|
|
||||||
Animation<double>? _routeAnimation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
if (_ready || _routeAnimation != null) return;
|
|
||||||
final animation = ModalRoute.of(context)?.animation;
|
|
||||||
if (animation == null || animation.isCompleted) {
|
|
||||||
_ready = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_routeAnimation = animation..addStatusListener(_onAnimationStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onAnimationStatus(AnimationStatus status) {
|
|
||||||
if (status == AnimationStatus.completed && mounted) {
|
|
||||||
setState(() => _ready = true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_routeAnimation?.removeStatusListener(_onAnimationStatus);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (!_ready) {
|
|
||||||
return const Center(child: AppProgressIndicator.large());
|
|
||||||
}
|
|
||||||
return SfPdfViewer.file(File(widget.path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FileViewerState extends State<FileViewer> {
|
class _FileViewerState extends State<FileViewer> {
|
||||||
final PhotoViewController photoViewController = PhotoViewController();
|
final PhotoViewController photoViewController = PhotoViewController();
|
||||||
|
|
||||||
late SettingsCubit settings = context.read<SettingsCubit>();
|
late SettingsCubit settings = context.read<SettingsCubit>();
|
||||||
late bool openExternal;
|
late bool openExternal;
|
||||||
Future<_FileKind>? _fileKind;
|
Future<FileKind>? _fileKind;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -200,7 +63,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
(_) => _openExternallyAndPop(),
|
(_) => _openExternallyAndPop(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_fileKind = _detectKind();
|
_fileKind = detectFileKind(widget.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,18 +82,6 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_FileKind> _detectKind() async {
|
|
||||||
final ext = widget.path.split('.').last.toLowerCase();
|
|
||||||
if (_imageExtensions.contains(ext)) return _FileKind.image;
|
|
||||||
if (ext == 'svg') return _FileKind.svg;
|
|
||||||
if (ext == 'pdf') return _FileKind.pdf;
|
|
||||||
if (_videoExtensions.contains(ext)) return _FileKind.video;
|
|
||||||
if (_audioExtensions.contains(ext)) return _FileKind.audio;
|
|
||||||
if (_textExtensions.contains(ext)) return _FileKind.text;
|
|
||||||
if (await _looksLikeText(widget.path)) return _FileKind.text;
|
|
||||||
return _FileKind.unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleAction(FileViewingActions value) async {
|
Future<void> _handleAction(FileViewingActions value) async {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case FileViewingActions.openExternal:
|
case FileViewingActions.openExternal:
|
||||||
@@ -360,7 +211,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
body: const Center(child: AppProgressIndicator.large()),
|
body: const Center(child: AppProgressIndicator.large()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return FutureBuilder<_FileKind>(
|
return FutureBuilder<FileKind>(
|
||||||
future: _fileKind,
|
future: _fileKind,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
@@ -370,19 +221,19 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
switch (snapshot.data!) {
|
switch (snapshot.data!) {
|
||||||
case _FileKind.image:
|
case FileKind.image:
|
||||||
return _buildImageView();
|
return _buildImageView();
|
||||||
case _FileKind.svg:
|
case FileKind.svg:
|
||||||
return _buildSvgView();
|
return _buildSvgView();
|
||||||
case _FileKind.pdf:
|
case FileKind.pdf:
|
||||||
return _buildPdfView();
|
return _buildPdfView();
|
||||||
case _FileKind.video:
|
case FileKind.video:
|
||||||
return _buildVideoView();
|
return _buildVideoView();
|
||||||
case _FileKind.audio:
|
case FileKind.audio:
|
||||||
return _buildAudioView();
|
return _buildAudioView();
|
||||||
case _FileKind.text:
|
case FileKind.text:
|
||||||
return _buildTextView();
|
return _buildTextView();
|
||||||
case _FileKind.unknown:
|
case FileKind.unknown:
|
||||||
return _buildUnknownView();
|
return _buildUnknownView();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -431,17 +282,17 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildPdfView() =>
|
Widget _buildPdfView() =>
|
||||||
Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path));
|
Scaffold(appBar: _appbar(), body: DeferredPdfViewer(path: widget.path));
|
||||||
|
|
||||||
Widget _buildVideoView() => Scaffold(
|
Widget _buildVideoView() => Scaffold(
|
||||||
appBar: _appbar(),
|
appBar: _appbar(),
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: _MediaPlayer(path: widget.path, isAudio: false),
|
body: MediaPlayer(path: widget.path, isAudio: false),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildAudioView() => Scaffold(
|
Widget _buildAudioView() => Scaffold(
|
||||||
appBar: _appbar(),
|
appBar: _appbar(),
|
||||||
body: _MediaPlayer(
|
body: MediaPlayer(
|
||||||
path: widget.path,
|
path: widget.path,
|
||||||
isAudio: true,
|
isAudio: true,
|
||||||
filename: widget.path.split('/').last,
|
filename: widget.path.split('/').last,
|
||||||
@@ -485,7 +336,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
),
|
),
|
||||||
SliverList.builder(
|
SliverList.builder(
|
||||||
itemCount: lines.length,
|
itemCount: lines.length,
|
||||||
itemBuilder: (context, i) => _CodeLine(
|
itemBuilder: (context, i) => CodeLine(
|
||||||
number: i + 1,
|
number: i + 1,
|
||||||
text: lines[i],
|
text: lines[i],
|
||||||
gutterWidth: gutterWidth,
|
gutterWidth: gutterWidth,
|
||||||
@@ -515,7 +366,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_UnknownPreviewBlock(remoteFile: widget.remoteFile),
|
UnknownPreviewBlock(remoteFile: widget.remoteFile),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
widget.path.split('/').last,
|
widget.path.split('/').last,
|
||||||
@@ -596,313 +447,3 @@ class _TextPayload {
|
|||||||
final bool truncated;
|
final bool truncated;
|
||||||
const _TextPayload({required this.content, required this.truncated});
|
const _TextPayload({required this.content, required this.truncated});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header block for the "Vorschau nicht verfügbar" screen.
|
|
||||||
///
|
|
||||||
/// Two visual modes — kept layout-equivalent so the screen looks identical
|
|
||||||
/// whether the server already said "no preview" or the probe failed late:
|
|
||||||
/// * **No preview available** (server said no, no remoteFile, or probe
|
|
||||||
/// errored): compact "file icon + 'Vorschau nicht verfügbar' text".
|
|
||||||
/// * **Preview rendering / loaded**: mid-sized thumbnail without text.
|
|
||||||
class _UnknownPreviewBlock extends StatefulWidget {
|
|
||||||
final RemoteFileRef? remoteFile;
|
|
||||||
const _UnknownPreviewBlock({required this.remoteFile});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_UnknownPreviewBlock> createState() => _UnknownPreviewBlockState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UnknownPreviewBlockState extends State<_UnknownPreviewBlock> {
|
|
||||||
static const double _previewSize = 180;
|
|
||||||
bool _failed = false;
|
|
||||||
|
|
||||||
Widget _compact(ThemeData theme) => Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.insert_drive_file_outlined, size: 60),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Vorschau nicht verfügbar',
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final remote = widget.remoteFile;
|
|
||||||
final canProbe =
|
|
||||||
remote != null &&
|
|
||||||
remote.hasPreview != false &&
|
|
||||||
remote.fileId != null &&
|
|
||||||
!_failed;
|
|
||||||
if (!canProbe) return _compact(theme);
|
|
||||||
return SizedBox(
|
|
||||||
width: _previewSize,
|
|
||||||
height: _previewSize,
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
httpHeaders: AccountData().authHeaders(),
|
|
||||||
imageUrl: _ncPreviewUrl(remote, width: 360),
|
|
||||||
fadeInDuration: Duration.zero,
|
|
||||||
fadeOutDuration: Duration.zero,
|
|
||||||
// Late probe failure: re-render into the compact layout so the
|
|
||||||
// screen doesn't keep a 180×180 box around a tiny icon. Deferred
|
|
||||||
// to the next frame because setState during build is illegal.
|
|
||||||
errorListener: (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) setState(() => _failed = true);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
placeholder: (_, _) =>
|
|
||||||
const Center(child: AppProgressIndicator.large()),
|
|
||||||
// Briefly empty while the post-frame setState swaps layouts.
|
|
||||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
|
||||||
imageBuilder: (_, imageProvider) => ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: Image(
|
|
||||||
image: imageProvider,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
width: _previewSize,
|
|
||||||
height: _previewSize,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MediaPlayer extends StatefulWidget {
|
|
||||||
final String path;
|
|
||||||
final bool isAudio;
|
|
||||||
final String? filename;
|
|
||||||
const _MediaPlayer({
|
|
||||||
required this.path,
|
|
||||||
required this.isAudio,
|
|
||||||
this.filename,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_MediaPlayer> createState() => _MediaPlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MediaPlayerState extends State<_MediaPlayer> {
|
|
||||||
VideoPlayerController? _video;
|
|
||||||
ChewieController? _chewie;
|
|
||||||
Object? _initError;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initialize() async {
|
|
||||||
final controller = VideoPlayerController.file(File(widget.path));
|
|
||||||
try {
|
|
||||||
await controller.initialize();
|
|
||||||
} on Object catch (e) {
|
|
||||||
await controller.dispose();
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _initError = e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!mounted) {
|
|
||||||
await controller.dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (widget.isAudio) {
|
|
||||||
controller.addListener(_onAudioTick);
|
|
||||||
setState(() => _video = controller);
|
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
_video = controller;
|
|
||||||
_chewie = ChewieController(
|
|
||||||
videoPlayerController: controller,
|
|
||||||
autoPlay: false,
|
|
||||||
looping: false,
|
|
||||||
allowFullScreen: true,
|
|
||||||
allowPlaybackSpeedChanging: true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onAudioTick() {
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_video?.removeListener(_onAudioTick);
|
|
||||||
_chewie?.dispose();
|
|
||||||
_video?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (_initError != null) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.error_outline, size: 48),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
widget.isAudio
|
|
||||||
? 'Audio kann nicht abgespielt werden'
|
|
||||||
: 'Video kann nicht abgespielt werden',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Format wird auf diesem Gerät nicht unterstützt. Über das Menü kannst du die Datei in einer anderen App öffnen.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (_video == null) {
|
|
||||||
return const Center(child: AppProgressIndicator.large());
|
|
||||||
}
|
|
||||||
if (widget.isAudio) {
|
|
||||||
return _AudioControls(
|
|
||||||
controller: _video!,
|
|
||||||
filename: widget.filename ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Chewie(controller: _chewie!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AudioControls extends StatelessWidget {
|
|
||||||
final VideoPlayerController controller;
|
|
||||||
final String filename;
|
|
||||||
const _AudioControls({required this.controller, required this.filename});
|
|
||||||
|
|
||||||
String _format(Duration d) {
|
|
||||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
||||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
||||||
if (d.inHours > 0) return '${d.inHours}:$m:$s';
|
|
||||||
return '$m:$s';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final value = controller.value;
|
|
||||||
final duration = value.duration;
|
|
||||||
final position = value.position;
|
|
||||||
final maxMs = duration.inMilliseconds == 0 ? 1 : duration.inMilliseconds;
|
|
||||||
final posMs = position.inMilliseconds.clamp(0, maxMs).toDouble();
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.audiotrack,
|
|
||||||
size: 96,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
filename,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
Slider(
|
|
||||||
min: 0,
|
|
||||||
max: maxMs.toDouble(),
|
|
||||||
value: posMs,
|
|
||||||
onChanged: (v) =>
|
|
||||||
controller.seekTo(Duration(milliseconds: v.toInt())),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_format(position),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_format(duration),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: 'audioPlayPause',
|
|
||||||
onPressed: () {
|
|
||||||
if (value.isPlaying) {
|
|
||||||
controller.pause();
|
|
||||||
} else {
|
|
||||||
controller.play();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CodeLine extends StatelessWidget {
|
|
||||||
final int number;
|
|
||||||
final String text;
|
|
||||||
final double gutterWidth;
|
|
||||||
const _CodeLine({
|
|
||||||
required this.number,
|
|
||||||
required this.text,
|
|
||||||
required this.gutterWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const TextStyle _codeStyle = TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 1.4,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final isEven = number.isEven;
|
|
||||||
return Container(
|
|
||||||
color: isEven ? theme.colorScheme.surfaceContainerLow : null,
|
|
||||||
padding: const EdgeInsets.only(left: 4, right: 12),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SelectionContainer.disabled(
|
|
||||||
child: SizedBox(
|
|
||||||
width: gutterWidth,
|
|
||||||
child: Text(
|
|
||||||
'$number',
|
|
||||||
textAlign: TextAlign.right,
|
|
||||||
style: _codeStyle.copyWith(color: theme.hintColor),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(child: Text(text.isEmpty ? ' ' : text, style: _codeStyle)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A single line of the text/code viewer: a right-aligned, non-selectable line
|
||||||
|
/// number gutter plus the selectable line content, with zebra striping.
|
||||||
|
class CodeLine extends StatelessWidget {
|
||||||
|
final int number;
|
||||||
|
final String text;
|
||||||
|
final double gutterWidth;
|
||||||
|
const CodeLine({
|
||||||
|
super.key,
|
||||||
|
required this.number,
|
||||||
|
required this.text,
|
||||||
|
required this.gutterWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const TextStyle _codeStyle = TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.4,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isEven = number.isEven;
|
||||||
|
return Container(
|
||||||
|
color: isEven ? theme.colorScheme.surfaceContainerLow : null,
|
||||||
|
padding: const EdgeInsets.only(left: 4, right: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SelectionContainer.disabled(
|
||||||
|
child: SizedBox(
|
||||||
|
width: gutterWidth,
|
||||||
|
child: Text(
|
||||||
|
'$number',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: _codeStyle.copyWith(color: theme.hintColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(text.isEmpty ? ' ' : text, style: _codeStyle)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
|
||||||
|
import '../app_progress_indicator.dart';
|
||||||
|
|
||||||
|
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
|
||||||
|
/// animation. Defer until the route enter animation completes.
|
||||||
|
class DeferredPdfViewer extends StatefulWidget {
|
||||||
|
const DeferredPdfViewer({super.key, required this.path});
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeferredPdfViewer> createState() => _DeferredPdfViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeferredPdfViewerState extends State<DeferredPdfViewer> {
|
||||||
|
bool _ready = false;
|
||||||
|
Animation<double>? _routeAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_ready || _routeAnimation != null) return;
|
||||||
|
final animation = ModalRoute.of(context)?.animation;
|
||||||
|
if (animation == null || animation.isCompleted) {
|
||||||
|
_ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_routeAnimation = animation..addStatusListener(_onAnimationStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAnimationStatus(AnimationStatus status) {
|
||||||
|
if (status == AnimationStatus.completed && mounted) {
|
||||||
|
setState(() => _ready = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_routeAnimation?.removeStatusListener(_onAnimationStatus);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_ready) {
|
||||||
|
return const Center(child: AppProgressIndicator.large());
|
||||||
|
}
|
||||||
|
return SfPdfViewer.file(File(widget.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
enum FileKind { image, svg, pdf, text, video, audio, unknown }
|
||||||
|
|
||||||
|
const Set<String> _imageExtensions = {
|
||||||
|
'png',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'webp',
|
||||||
|
'gif',
|
||||||
|
'bmp',
|
||||||
|
'wbmp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Set<String> _videoExtensions = {
|
||||||
|
'mp4',
|
||||||
|
'm4v',
|
||||||
|
'mov',
|
||||||
|
'webm',
|
||||||
|
'mkv',
|
||||||
|
'3gp',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// ogg/opus/flac are Android-only; iOS init errors fall through to the
|
||||||
|
/// "format not supported" message.
|
||||||
|
const Set<String> _audioExtensions = {
|
||||||
|
'mp3',
|
||||||
|
'm4a',
|
||||||
|
'aac',
|
||||||
|
'wav',
|
||||||
|
'flac',
|
||||||
|
'ogg',
|
||||||
|
'oga',
|
||||||
|
'opus',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Unknown extensions still get a content sniff via [_looksLikeText].
|
||||||
|
const Set<String> _textExtensions = {
|
||||||
|
'txt', 'md', 'markdown', 'rst', 'log',
|
||||||
|
'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
|
||||||
|
'csv', 'tsv', 'tab',
|
||||||
|
'ini', 'conf', 'cfg', 'env', 'properties',
|
||||||
|
'html', 'htm', 'xhtml',
|
||||||
|
'css', 'scss', 'sass', 'less',
|
||||||
|
'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx',
|
||||||
|
'dart', 'java', 'kt', 'kts', 'groovy', 'scala', 'swift',
|
||||||
|
'py', 'rb', 'pl', 'lua', 'r',
|
||||||
|
'go', 'rs', 'zig',
|
||||||
|
'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'm', 'mm',
|
||||||
|
'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||||
|
'sql', 'graphql', 'gql',
|
||||||
|
'gitignore', 'gitattributes', 'editorconfig', 'dockerignore',
|
||||||
|
'dockerfile', 'makefile', 'cmake',
|
||||||
|
'tex', 'bib',
|
||||||
|
'srt', 'vtt',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Detects the [FileKind] of the file at [path] from its extension, falling
|
||||||
|
/// back to an 8 KB content sniff ([_looksLikeText]) for unknown extensions.
|
||||||
|
Future<FileKind> detectFileKind(String path) async {
|
||||||
|
final ext = path.split('.').last.toLowerCase();
|
||||||
|
if (_imageExtensions.contains(ext)) return FileKind.image;
|
||||||
|
if (ext == 'svg') return FileKind.svg;
|
||||||
|
if (ext == 'pdf') return FileKind.pdf;
|
||||||
|
if (_videoExtensions.contains(ext)) return FileKind.video;
|
||||||
|
if (_audioExtensions.contains(ext)) return FileKind.audio;
|
||||||
|
if (_textExtensions.contains(ext)) return FileKind.text;
|
||||||
|
if (await _looksLikeText(path)) return FileKind.text;
|
||||||
|
return FileKind.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify.
|
||||||
|
Future<bool> _looksLikeText(String path) async {
|
||||||
|
final file = File(path);
|
||||||
|
RandomAccessFile? raf;
|
||||||
|
try {
|
||||||
|
final length = await file.length();
|
||||||
|
if (length == 0) return true;
|
||||||
|
raf = await file.open();
|
||||||
|
final sample = await raf.read(min(length, 8192));
|
||||||
|
if (sample.contains(0)) return false;
|
||||||
|
utf8.decode(sample);
|
||||||
|
return true;
|
||||||
|
} on Object {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await raf?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:chewie/chewie.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../app_progress_indicator.dart';
|
||||||
|
|
||||||
|
/// Plays a local video (via Chewie) or audio file (via [_AudioControls]).
|
||||||
|
/// Reports an inline "format not supported" message when the platform can't
|
||||||
|
/// initialize the file.
|
||||||
|
class MediaPlayer extends StatefulWidget {
|
||||||
|
final String path;
|
||||||
|
final bool isAudio;
|
||||||
|
final String? filename;
|
||||||
|
const MediaPlayer({
|
||||||
|
super.key,
|
||||||
|
required this.path,
|
||||||
|
required this.isAudio,
|
||||||
|
this.filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MediaPlayer> createState() => _MediaPlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaPlayerState extends State<MediaPlayer> {
|
||||||
|
VideoPlayerController? _video;
|
||||||
|
ChewieController? _chewie;
|
||||||
|
Object? _initError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initialize() async {
|
||||||
|
final controller = VideoPlayerController.file(File(widget.path));
|
||||||
|
try {
|
||||||
|
await controller.initialize();
|
||||||
|
} on Object catch (e) {
|
||||||
|
await controller.dispose();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _initError = e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) {
|
||||||
|
await controller.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (widget.isAudio) {
|
||||||
|
controller.addListener(_onAudioTick);
|
||||||
|
setState(() => _video = controller);
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_video = controller;
|
||||||
|
_chewie = ChewieController(
|
||||||
|
videoPlayerController: controller,
|
||||||
|
autoPlay: false,
|
||||||
|
looping: false,
|
||||||
|
allowFullScreen: true,
|
||||||
|
allowPlaybackSpeedChanging: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAudioTick() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_video?.removeListener(_onAudioTick);
|
||||||
|
_chewie?.dispose();
|
||||||
|
_video?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_initError != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
widget.isAudio
|
||||||
|
? 'Audio kann nicht abgespielt werden'
|
||||||
|
: 'Video kann nicht abgespielt werden',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Format wird auf diesem Gerät nicht unterstützt. Über das Menü kannst du die Datei in einer anderen App öffnen.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_video == null) {
|
||||||
|
return const Center(child: AppProgressIndicator.large());
|
||||||
|
}
|
||||||
|
if (widget.isAudio) {
|
||||||
|
return _AudioControls(
|
||||||
|
controller: _video!,
|
||||||
|
filename: widget.filename ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Chewie(controller: _chewie!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudioControls extends StatelessWidget {
|
||||||
|
final VideoPlayerController controller;
|
||||||
|
final String filename;
|
||||||
|
const _AudioControls({required this.controller, required this.filename});
|
||||||
|
|
||||||
|
String _format(Duration d) {
|
||||||
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
if (d.inHours > 0) return '${d.inHours}:$m:$s';
|
||||||
|
return '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final value = controller.value;
|
||||||
|
final duration = value.duration;
|
||||||
|
final position = value.position;
|
||||||
|
final maxMs = duration.inMilliseconds == 0 ? 1 : duration.inMilliseconds;
|
||||||
|
final posMs = position.inMilliseconds.clamp(0, maxMs).toDouble();
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.audiotrack,
|
||||||
|
size: 96,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
filename,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Slider(
|
||||||
|
min: 0,
|
||||||
|
max: maxMs.toDouble(),
|
||||||
|
value: posMs,
|
||||||
|
onChanged: (v) =>
|
||||||
|
controller.seekTo(Duration(milliseconds: v.toInt())),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_format(position),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_format(duration),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FloatingActionButton(
|
||||||
|
heroTag: 'audioPlayPause',
|
||||||
|
onPressed: () {
|
||||||
|
if (value.isPlaying) {
|
||||||
|
controller.pause();
|
||||||
|
} else {
|
||||||
|
controller.play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../model/account_data.dart';
|
||||||
|
import '../../model/endpoint_data.dart';
|
||||||
|
import '../../share_intent/remote_file_ref.dart';
|
||||||
|
import '../app_progress_indicator.dart';
|
||||||
|
|
||||||
|
/// Nextcloud's `/index.php/core/preview` endpoint — returns a rasterized
|
||||||
|
/// thumbnail for any file the server has a preview provider for (images,
|
||||||
|
/// PDFs with the right backend, Office in some setups). Falls back to an
|
||||||
|
/// HTTP 404 when no preview is available, which lets [CachedNetworkImage]
|
||||||
|
/// trigger its `errorWidget`. Prefers `fileId` because the path variant
|
||||||
|
/// is unreliable on some server configurations.
|
||||||
|
String _ncPreviewUrl(RemoteFileRef remote, {int width = 1024}) {
|
||||||
|
final host = EndpointData().nextcloud().full();
|
||||||
|
final id = remote.fileId;
|
||||||
|
final selector = id != null
|
||||||
|
? 'fileId=$id'
|
||||||
|
: 'file=${Uri.encodeQueryComponent(remote.path)}';
|
||||||
|
return 'https://$host/index.php/core/preview?$selector&x=$width&y=-1&a=1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header block for the "Vorschau nicht verfügbar" screen.
|
||||||
|
///
|
||||||
|
/// Two visual modes — kept layout-equivalent so the screen looks identical
|
||||||
|
/// whether the server already said "no preview" or the probe failed late:
|
||||||
|
/// * **No preview available** (server said no, no remoteFile, or probe
|
||||||
|
/// errored): compact "file icon + 'Vorschau nicht verfügbar' text".
|
||||||
|
/// * **Preview rendering / loaded**: mid-sized thumbnail without text.
|
||||||
|
class UnknownPreviewBlock extends StatefulWidget {
|
||||||
|
final RemoteFileRef? remoteFile;
|
||||||
|
const UnknownPreviewBlock({super.key, required this.remoteFile});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UnknownPreviewBlock> createState() => _UnknownPreviewBlockState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnknownPreviewBlockState extends State<UnknownPreviewBlock> {
|
||||||
|
static const double _previewSize = 180;
|
||||||
|
bool _failed = false;
|
||||||
|
|
||||||
|
Widget _compact(ThemeData theme) => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.insert_drive_file_outlined, size: 60),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Vorschau nicht verfügbar',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final remote = widget.remoteFile;
|
||||||
|
final canProbe =
|
||||||
|
remote != null &&
|
||||||
|
remote.hasPreview != false &&
|
||||||
|
remote.fileId != null &&
|
||||||
|
!_failed;
|
||||||
|
if (!canProbe) return _compact(theme);
|
||||||
|
return SizedBox(
|
||||||
|
width: _previewSize,
|
||||||
|
height: _previewSize,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
httpHeaders: AccountData().authHeaders(),
|
||||||
|
imageUrl: _ncPreviewUrl(remote, width: 360),
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
// Late probe failure: re-render into the compact layout so the
|
||||||
|
// screen doesn't keep a 180×180 box around a tiny icon. Deferred
|
||||||
|
// to the next frame because setState during build is illegal.
|
||||||
|
errorListener: (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() => _failed = true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
placeholder: (_, _) =>
|
||||||
|
const Center(child: AppProgressIndicator.large()),
|
||||||
|
// Briefly empty while the post-frame setState swaps layouts.
|
||||||
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||||
|
imageBuilder: (_, imageProvider) => ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image(
|
||||||
|
image: imageProvider,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
width: _previewSize,
|
||||||
|
height: _previewSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'async_action_button.dart';
|
||||||
|
|
||||||
|
/// Single-line text-input dialog. The confirm action runs [onConfirm] with the
|
||||||
|
/// trimmed input via [AsyncDialogAction], so it shows a spinner and an inline
|
||||||
|
/// error and only closes on success. Throw inside [onConfirm] to keep the
|
||||||
|
/// dialog open with a message (e.g. for empty or duplicate input).
|
||||||
|
void showPromptDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String confirmButton,
|
||||||
|
required Future<void> Function(String value) onConfirm,
|
||||||
|
String label = 'Name',
|
||||||
|
String initialValue = '',
|
||||||
|
AsyncErrorBuilder? errorBuilder,
|
||||||
|
}) {
|
||||||
|
final controller = TextEditingController(text: initialValue);
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogCtx) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(labelText: label),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
AsyncDialogAction(
|
||||||
|
confirmLabel: confirmButton,
|
||||||
|
onConfirm: () => onConfirm(controller.text.trim()),
|
||||||
|
errorBuilder: errorBuilder,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).whenComplete(controller.dispose);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../app_progress_indicator.dart';
|
||||||
import 'pm_document_view.dart';
|
import 'pm_document_view.dart';
|
||||||
import 'pm_node.dart';
|
import 'pm_node.dart';
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ class _PmJsonViewState extends State<PmJsonView> {
|
|||||||
if (shown == null) {
|
if (shown == null) {
|
||||||
return const SizedBox(
|
return const SizedBox(
|
||||||
height: 160,
|
height: 160,
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: AppProgressIndicator.large()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return PmDocumentView(doc: shown, onLinkTap: widget.onLinkTap);
|
return PmDocumentView(doc: shown, onLinkTap: widget.onLinkTap);
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
extension StringExtensions on String {
|
|
||||||
String capitalize() =>
|
|
||||||
'${this[0].toUpperCase()}${substring(1).toLowerCase()}';
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class UnimplementedDialog {
|
|
||||||
static void show(BuildContext context) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) =>
|
|
||||||
const AlertDialog(content: Text('Not implemented yet')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+116
-13
@@ -1,8 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
@@ -10,6 +10,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import '../model/account_data.dart';
|
import '../model/account_data.dart';
|
||||||
import '../model/endpoint_data.dart';
|
import '../model/endpoint_data.dart';
|
||||||
import '../push/push_avatar.dart';
|
import '../push/push_avatar.dart';
|
||||||
|
import 'avatar_disk_cache.dart';
|
||||||
|
|
||||||
class UserAvatar extends StatefulWidget {
|
class UserAvatar extends StatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -84,10 +85,12 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
|
|||||||
if (id == null) {
|
if (id == null) {
|
||||||
_resolvedAvatars.clear();
|
_resolvedAvatars.clear();
|
||||||
_pendingAvatars.clear();
|
_pendingAvatars.clear();
|
||||||
|
unawaited(AvatarDiskCache.instance.clear());
|
||||||
} else if (isGroup == true) {
|
} else if (isGroup == true) {
|
||||||
final url = avatarUrl(id: id, isGroup: true);
|
final url = avatarUrl(id: id, isGroup: true);
|
||||||
_resolvedAvatars.remove(url);
|
_resolvedAvatars.remove(url);
|
||||||
_pendingAvatars.remove(url);
|
_pendingAvatars.remove(url);
|
||||||
|
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: true));
|
||||||
// Keep the push-notification disk cache in sync — it serves the same
|
// Keep the push-notification disk cache in sync — it serves the same
|
||||||
// room avatar to the FCM background isolate.
|
// room avatar to the FCM background isolate.
|
||||||
unawaited(PushAvatarStore.evict(id));
|
unawaited(PushAvatarStore.evict(id));
|
||||||
@@ -97,6 +100,7 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
|
|||||||
final prefix = 'https://$host/avatar/$id/';
|
final prefix = 'https://$host/avatar/$id/';
|
||||||
_resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix));
|
_resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix));
|
||||||
_pendingAvatars.removeWhere((url, _) => url.startsWith(prefix));
|
_pendingAvatars.removeWhere((url, _) => url.startsWith(prefix));
|
||||||
|
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: false));
|
||||||
}
|
}
|
||||||
_avatarCacheGeneration.value++;
|
_avatarCacheGeneration.value++;
|
||||||
}
|
}
|
||||||
@@ -170,18 +174,117 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
_payload = cached.payload;
|
_payload = cached.payload;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Capture the subject once — later async steps must not read widget.* since
|
||||||
|
// the widget may have been recycled onto a different id by then.
|
||||||
|
final id = widget.id;
|
||||||
|
final isGroup = widget.isGroup;
|
||||||
|
final size = _resolvedRequestSize();
|
||||||
|
|
||||||
|
// Persistent disk cache: on a warm session (cache directory already known)
|
||||||
|
// this hits synchronously, so a cold app start paints the last-known
|
||||||
|
// picture on the first frame instead of a blank placeholder.
|
||||||
|
final diskBytes = AvatarDiskCache.instance.readSync(
|
||||||
|
id: id,
|
||||||
|
isGroup: isGroup,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
if (diskBytes != null) {
|
||||||
|
final payload = _payloadFromBytes(diskBytes);
|
||||||
|
_payload = payload;
|
||||||
|
_writeAvatarCache(url, payload);
|
||||||
|
} else {
|
||||||
_payload = null;
|
_payload = null;
|
||||||
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
|
}
|
||||||
pending.then((p) {
|
unawaited(_resolve(url, id, isGroup, size, haveBytes: _payload != null));
|
||||||
_writeAvatarCache(url, p);
|
|
||||||
_pendingAvatars.remove(url);
|
|
||||||
if (!mounted || _url() != url) return;
|
|
||||||
setState(() => _payload = p);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_AvatarPayload?> _fetch(String url) async {
|
/// Fills the placeholder from disk (async path, for the first avatar of a
|
||||||
|
/// session), then always refreshes over the network so a changed server-side
|
||||||
|
/// picture replaces the cached one. Network work is deduped across every
|
||||||
|
/// widget showing the same avatar via [_pendingAvatars].
|
||||||
|
Future<void> _resolve(
|
||||||
|
String url,
|
||||||
|
String id,
|
||||||
|
bool isGroup,
|
||||||
|
int size, {
|
||||||
|
required bool haveBytes,
|
||||||
|
}) async {
|
||||||
|
if (!haveBytes) {
|
||||||
|
final diskBytes = await AvatarDiskCache.instance.read(
|
||||||
|
id: id,
|
||||||
|
isGroup: isGroup,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
if (diskBytes != null && mounted && _url() == url && _payload == null) {
|
||||||
|
final payload = _payloadFromBytes(diskBytes);
|
||||||
|
_writeAvatarCache(url, payload);
|
||||||
|
setState(() => _payload = payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pending = _pendingAvatars.putIfAbsent(url, () {
|
||||||
|
final future = _fetch(url);
|
||||||
|
future.whenComplete(() {
|
||||||
|
if (identical(_pendingAvatars[url], future)) _pendingAvatars.remove(url);
|
||||||
|
});
|
||||||
|
return future;
|
||||||
|
});
|
||||||
|
|
||||||
|
_AvatarPayload? fresh;
|
||||||
try {
|
try {
|
||||||
|
fresh = await pending;
|
||||||
|
} on Object {
|
||||||
|
// Transient failure (offline, 5xx). Keep showing the cached picture; the
|
||||||
|
// next mount retries. Deliberately no null-cache so we don't mask it.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_commit(url, id, isGroup, size, fresh);
|
||||||
|
if (!mounted || _url() != url) return;
|
||||||
|
if (fresh == null) {
|
||||||
|
// HTTP 404 — the avatar was removed server-side. Fall back to the icon.
|
||||||
|
if (_payload != null) setState(() => _payload = null);
|
||||||
|
} else if (!_sameBytes(_payload, fresh)) {
|
||||||
|
setState(() => _payload = fresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persists a resolved result to the in-memory and disk caches. Uses the
|
||||||
|
// captured subject (not widget.*) so a recycled widget can't misfile bytes.
|
||||||
|
void _commit(
|
||||||
|
String url,
|
||||||
|
String id,
|
||||||
|
bool isGroup,
|
||||||
|
int size,
|
||||||
|
_AvatarPayload? payload,
|
||||||
|
) {
|
||||||
|
_writeAvatarCache(url, payload);
|
||||||
|
if (payload != null) {
|
||||||
|
unawaited(
|
||||||
|
AvatarDiskCache.instance.write(
|
||||||
|
id: id,
|
||||||
|
isGroup: isGroup,
|
||||||
|
size: size,
|
||||||
|
bytes: payload.bytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: isGroup));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static _AvatarPayload _payloadFromBytes(Uint8List bytes) =>
|
||||||
|
_AvatarPayload(bytes, _looksLikeSvg(bytes));
|
||||||
|
|
||||||
|
static bool _sameBytes(_AvatarPayload? a, _AvatarPayload? b) {
|
||||||
|
if (a == null || b == null) return a == b;
|
||||||
|
return listEquals(a.bytes, b.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the avatar bytes, `null` for a definitive miss (HTTP 404 — no
|
||||||
|
/// avatar exists), or throws on a transient error (offline, non-200) so the
|
||||||
|
/// caller keeps the cached picture instead of blanking it.
|
||||||
|
Future<_AvatarPayload?> _fetch(String url) async {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
Uri.parse(url),
|
Uri.parse(url),
|
||||||
headers: {
|
headers: {
|
||||||
@@ -189,15 +292,15 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
|
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null;
|
if (response.statusCode == 404) return null;
|
||||||
|
if (response.statusCode != 200 || response.bodyBytes.isEmpty) {
|
||||||
|
throw Exception('avatar fetch failed: HTTP ${response.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
|
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
|
||||||
final bytes = response.bodyBytes;
|
final bytes = response.bodyBytes;
|
||||||
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
|
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
|
||||||
return _AvatarPayload(bytes, isSvg);
|
return _AvatarPayload(bytes, isSvg);
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _looksLikeSvg(Uint8List bytes) {
|
static bool _looksLikeSvg(Uint8List bytes) {
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ void main() {
|
|||||||
final end = dt.add(const Duration(minutes: 45));
|
final end = dt.add(const Duration(minutes: 45));
|
||||||
expect(dt.timeRangeTo(end), '09:07 - 09:52');
|
expect(dt.timeRangeTo(end), '09:07 - 09:52');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('weekKey renders a zero-padded yyyyMMdd key', () {
|
||||||
|
expect(dt.weekKey(), '20260508');
|
||||||
|
expect(DateTime(2026, 12, 31).weekKey(), '20261231');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('formatDateRelativeShort', () {
|
group('formatDateRelativeShort', () {
|
||||||
|
|||||||
@@ -23,15 +23,11 @@ Widget _host({
|
|||||||
List<TickerNavSection>? sections,
|
List<TickerNavSection>? sections,
|
||||||
String? initialSlug,
|
String? initialSlug,
|
||||||
void Function(String? slug)? onSelectionChanged,
|
void Function(String? slug)? onSelectionChanged,
|
||||||
String? homePublishedAt,
|
|
||||||
VoidCallback? onRefreshHome,
|
|
||||||
}) => MaterialApp(
|
}) => MaterialApp(
|
||||||
home: TickerScaffold(
|
home: TickerScaffold(
|
||||||
sections: sections ?? _sections(),
|
sections: sections ?? _sections(),
|
||||||
initialSlug: initialSlug,
|
initialSlug: initialSlug,
|
||||||
onSelectionChanged: onSelectionChanged,
|
onSelectionChanged: onSelectionChanged,
|
||||||
homePublishedAt: homePublishedAt,
|
|
||||||
onRefreshHome: onRefreshHome,
|
|
||||||
homeBuilder: (context, onLinkTap) => const Text('HOME'),
|
homeBuilder: (context, onLinkTap) => const Text('HOME'),
|
||||||
pageBuilder: (context, slug, onLinkTap) => Text('PAGE:$slug'),
|
pageBuilder: (context, slug, onLinkTap) => Text('PAGE:$slug'),
|
||||||
),
|
),
|
||||||
@@ -231,50 +227,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('home date button', () {
|
|
||||||
testWidgets('shows the publish date on home and refreshes on tap', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
var refreshed = 0;
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_host(
|
|
||||||
homePublishedAt: '2026-02-17T14:30:00',
|
|
||||||
onRefreshHome: () => refreshed++,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('17.02.2026, 14:30'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text('17.02.2026, 14:30'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(refreshed, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('hides the date button when there is no date', (tester) async {
|
|
||||||
await tester.pumpWidget(_host());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byIcon(Icons.schedule), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('hides the date button once a page is open', (tester) async {
|
|
||||||
tester.view.physicalSize = const Size(1200, 2000);
|
|
||||||
tester.view.devicePixelRatio = 1.0;
|
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
|
||||||
await tester.pumpWidget(_host(homePublishedAt: '2026-02-17T14:30:00'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byIcon(Icons.schedule), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Über uns'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byIcon(Icons.schedule), findsNothing);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('back gesture', () {
|
group('back gesture', () {
|
||||||
testWidgets('pops from a sub-page back to home via the tab navigator', (
|
testWidgets('pops from a sub-page back to home via the tab navigator', (
|
||||||
tester,
|
tester,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jiffy/jiffy.dart';
|
||||||
|
import 'package:marianum_mobile/view/pages/ticker/widgets/ticker_updated_bar.dart';
|
||||||
|
|
||||||
|
Widget _host(String? publishedAt) =>
|
||||||
|
MaterialApp(home: Scaffold(body: TickerUpdatedBar(publishedAt: publishedAt)));
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() async {
|
||||||
|
await Jiffy.setLocale('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders the formatted publish date', (tester) async {
|
||||||
|
await tester.pumpWidget(_host('2026-02-17T14:30:00'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Aktualisiert am 17.02.2026 14:30'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.schedule), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders nothing without a date', (tester) async {
|
||||||
|
await tester.pumpWidget(_host(null));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.schedule), findsNothing);
|
||||||
|
expect(find.textContaining('Aktualisiert'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders nothing for an unparseable date', (tester) async {
|
||||||
|
await tester.pumpWidget(_host('not-a-date'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.schedule), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/widget/avatar_disk_cache.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AvatarDiskCache.fileName', () {
|
||||||
|
test('user files carry the size, group files do not', () {
|
||||||
|
expect(
|
||||||
|
AvatarDiskCache.fileName(id: 'alice', isGroup: false, size: 256),
|
||||||
|
'u_616c696365_256',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
AvatarDiskCache.fileName(id: 'alice', isGroup: false, size: 64),
|
||||||
|
'u_616c696365_64',
|
||||||
|
);
|
||||||
|
// Groups are served at one fixed size — the size argument is ignored.
|
||||||
|
expect(
|
||||||
|
AvatarDiskCache.fileName(id: 'room1', isGroup: true, size: 512),
|
||||||
|
AvatarDiskCache.fileName(id: 'room1', isGroup: true, size: 64),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
AvatarDiskCache.fileName(id: 'room1', isGroup: true, size: 0),
|
||||||
|
startsWith('g_'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names are file-safe even for exotic ids', () {
|
||||||
|
final name = AvatarDiskCache.fileName(
|
||||||
|
id: 'a/b c@d',
|
||||||
|
isGroup: false,
|
||||||
|
size: 128,
|
||||||
|
);
|
||||||
|
expect(name, matches(RegExp(r'^u_[0-9a-f]+_128$')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a short id evict prefix never matches a longer id file (hex + _ '
|
||||||
|
'separator keeps the boundary unambiguous)',
|
||||||
|
() {
|
||||||
|
// Eviction deletes files starting with `u_<hex(id)>_`.
|
||||||
|
final shortPrefix =
|
||||||
|
'u_${AvatarDiskCache.fileName(id: 'a', isGroup: false, size: 1).split('_')[1]}_';
|
||||||
|
final longFile = AvatarDiskCache.fileName(
|
||||||
|
id: 'a_b',
|
||||||
|
isGroup: false,
|
||||||
|
size: 256,
|
||||||
|
);
|
||||||
|
expect(longFile.startsWith(shortPrefix), isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user