From 6d010afa8ae4783153c8c5169626bab60232ce92 Mon Sep 17 00:00:00 2001 From: Erick Namukolo Date: Fri, 1 Aug 2025 23:29:50 +0200 Subject: [PATCH 1/3] formmatted time properly --- lib/features/events/widgets/event_card.dart | 2 +- lib/features/overview/widgets/metric_card.dart | 3 ++- lib/features/overview/widgets/stat_card.dart | 14 +++++++++++--- lib/utils/utils.dart | 17 +++++++++++++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/features/events/widgets/event_card.dart b/lib/features/events/widgets/event_card.dart index cd44774..5b18e21 100644 --- a/lib/features/events/widgets/event_card.dart +++ b/lib/features/events/widgets/event_card.dart @@ -64,7 +64,7 @@ class EventCard extends StatelessWidget { ), ], ), - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, ), ), diff --git a/lib/features/overview/widgets/metric_card.dart b/lib/features/overview/widgets/metric_card.dart index 42facd2..c43194a 100644 --- a/lib/features/overview/widgets/metric_card.dart +++ b/lib/features/overview/widgets/metric_card.dart @@ -1,3 +1,4 @@ +import 'package:country_codes/country_codes.dart'; import 'package:country_flags/country_flags.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -47,7 +48,7 @@ class MetricCard extends StatelessWidget { shape: const RoundedRectangle(2), ), Text( - met.x, + '${CountryCodes.name(locale: Locale('en-GB', met.x))}', style: kBodyTextStyle.copyWith( fontWeight: FontWeight.w700, ), diff --git a/lib/features/overview/widgets/stat_card.dart b/lib/features/overview/widgets/stat_card.dart index fa7a16d..5be4992 100644 --- a/lib/features/overview/widgets/stat_card.dart +++ b/lib/features/overview/widgets/stat_card.dart @@ -3,6 +3,7 @@ import 'package:icons_plus/icons_plus.dart'; import 'package:pulse/features/overview/repo/overview_repo.dart'; import 'package:pulse/utils/text.dart'; import 'package:intl/intl.dart'; +import 'package:pulse/utils/utils.dart'; import '../../../utils/colors.dart'; import '../../../widgets/container_wrapper.dart'; @@ -25,9 +26,16 @@ class StatCard extends StatelessWidget { children: [ Text(stat.key.toUpperCase(), style: kBodyTitleTextStyle.copyWith(color: kGreyColor)), - Text(NumberFormat.compact().format((stat.value as Map)['value']), - style: kTitleTextStyle.copyWith( - fontSize: kTitleTextStyle.fontSize! + 8)), + if (stat.key.toLowerCase() == 'totaltime') + FittedBox( + child: Text(formatDuration((stat.value as Map)['value']), + style: kTitleTextStyle.copyWith( + fontSize: kTitleTextStyle.fontSize! + 8)), + ) + else + Text(NumberFormat.compact().format((stat.value as Map)['value']), + style: kTitleTextStyle.copyWith( + fontSize: kTitleTextStyle.fontSize! + 8)), if (stat.value['prev'] != -1) Container( padding: EdgeInsets.symmetric(horizontal: 14.0, vertical: 4), diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 5632899..1cb3bec 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -66,3 +66,20 @@ Color getColorFromIndex(int index) { final hslColor = HSLColor.fromAHSL(1.0, hue.toDouble(), 0.6, 0.5); return hslColor.toColor(); } + +String formatDuration(int seconds) { + final duration = Duration(seconds: seconds); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final secs = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${_twoDigits(hours)}h ${_twoDigits(minutes)}m ${_twoDigits(secs)}s'; + } else if (minutes > 0) { + return '${_twoDigits(minutes)}m ${_twoDigits(secs)}s'; + } else { + return '${secs}s'; + } +} + +String _twoDigits(int n) => n.toString().padLeft(2, '0'); From c2fbfa3dadaa1175e8ee104f529371adf634416b Mon Sep 17 00:00:00 2001 From: Erick Namukolo Date: Sat, 2 Aug 2025 00:09:40 +0200 Subject: [PATCH 2/3] added activity on sessions screen --- lib/features/events/models/event.dart | 8 +- lib/features/events/models/event.g.dart | 4 +- lib/features/events/widgets/event_card.dart | 19 +- .../overview/widgets/metric_card.dart | 48 +-- .../sessions/cubit/sessions_cubit.dart | 18 + .../sessions/cubit/sessions_state.dart | 6 +- lib/features/sessions/repo/sessions_repo.dart | 19 ++ .../screens/session_details_screen.dart | 309 +++++++++++------- lib/utils/extensions.dart | 1 - 9 files changed, 276 insertions(+), 156 deletions(-) diff --git a/lib/features/events/models/event.dart b/lib/features/events/models/event.dart index 1c67897..c916cb6 100644 --- a/lib/features/events/models/event.dart +++ b/lib/features/events/models/event.dart @@ -3,8 +3,8 @@ part 'event.g.dart'; @JsonSerializable() class Event { - String id; - String sessionId; + String? id; + String? sessionId; DateTime createdAt; String eventName; String urlPath; @@ -12,8 +12,8 @@ class Event { Event({ required this.createdAt, - required this.id, - required this.sessionId, + this.id, + this.sessionId, required this.eventName, required this.urlPath, required this.referrerDomain, diff --git a/lib/features/events/models/event.g.dart b/lib/features/events/models/event.g.dart index 232afcf..748d335 100644 --- a/lib/features/events/models/event.g.dart +++ b/lib/features/events/models/event.g.dart @@ -8,8 +8,8 @@ part of 'event.dart'; Event _$EventFromJson(Map json) => Event( createdAt: DateTime.parse(json['createdAt'] as String), - id: json['id'] as String, - sessionId: json['sessionId'] as String, + id: json['id'] as String?, + sessionId: json['sessionId'] as String?, eventName: json['eventName'] as String, urlPath: json['urlPath'] as String, referrerDomain: json['referrerDomain'] as String, diff --git a/lib/features/events/widgets/event_card.dart b/lib/features/events/widgets/event_card.dart index 5b18e21..0a364b0 100644 --- a/lib/features/events/widgets/event_card.dart +++ b/lib/features/events/widgets/event_card.dart @@ -20,17 +20,18 @@ class EventCard extends StatelessWidget { spacing: 15, crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox( - width: 50, - height: 50, - child: BoringAvatar( - name: event.sessionId, - type: BoringAvatarType.beam, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6.0), + if (event.sessionId != null) + SizedBox( + width: 50, + height: 50, + child: BoringAvatar( + name: event.sessionId!, + type: BoringAvatarType.beam, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6.0), + ), ), ), - ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/overview/widgets/metric_card.dart b/lib/features/overview/widgets/metric_card.dart index c43194a..3285bc8 100644 --- a/lib/features/overview/widgets/metric_card.dart +++ b/lib/features/overview/widgets/metric_card.dart @@ -38,28 +38,34 @@ class MetricCard extends StatelessWidget { ), ), if (state.metric == 'Country') - Row( - spacing: 10, - children: [ - CountryFlag.fromCountryCode( - met.x, - width: 30, - height: 20, - shape: const RoundedRectangle(2), - ), - Text( - '${CountryCodes.name(locale: Locale('en-GB', met.x))}', - style: kBodyTextStyle.copyWith( - fontWeight: FontWeight.w700, + Expanded( + child: Row( + spacing: 10, + children: [ + CountryFlag.fromCountryCode( + met.x, + width: 30, + height: 20, + shape: const RoundedRectangle(2), ), - ), - Text( - '(${NumberFormat.compact().format(met.y)})', - style: kBodyTextStyle.copyWith( - fontWeight: FontWeight.w700, + Expanded( + child: Text( + '${CountryCodes.name(locale: Locale('en-GB', met.x))}', + maxLines: 1, + style: kBodyTextStyle.copyWith( + fontWeight: FontWeight.w700, + overflow: TextOverflow.ellipsis, + ), + ), ), - ), - ], + Text( + '(${NumberFormat.compact().format(met.y)})', + style: kBodyTextStyle.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), ) else Text( @@ -68,7 +74,7 @@ class MetricCard extends StatelessWidget { fontWeight: FontWeight.w700, ), ), - const Spacer(), + if (state.metric != 'Country') const Spacer(), Text( '${OverviewRepo().getMetricPercentage(met.y, state.metrics.map((e) => e.y).toList()).toStringAsFixed(1)} %', style: kBodyTextStyle.copyWith( diff --git a/lib/features/sessions/cubit/sessions_cubit.dart b/lib/features/sessions/cubit/sessions_cubit.dart index 02816e3..777534f 100644 --- a/lib/features/sessions/cubit/sessions_cubit.dart +++ b/lib/features/sessions/cubit/sessions_cubit.dart @@ -1,5 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:pulse/features/events/models/event.dart'; import 'package:pulse/features/sessions/model/session.dart'; import 'package:pulse/features/sessions/repo/sessions_repo.dart'; @@ -34,4 +35,21 @@ class SessionsCubit extends Cubit { state.copyWith(appState: AppState.error, errorMessage: e.toString())); } } + + Future getSessionEvents({ + required String websiteId, + required String id, + DateTime? start, + DateTime? end, + }) async { + emit(state.copyWith(appState: AppState.loading, events: [])); + try { + var res = await SessionsRepo().getSessionEvents( + id: id, start: start, end: end, websiteId: websiteId); + emit(state.copyWith(appState: AppState.complete, events: res)); + } catch (e) { + emit( + state.copyWith(appState: AppState.error, errorMessage: e.toString())); + } + } } diff --git a/lib/features/sessions/cubit/sessions_state.dart b/lib/features/sessions/cubit/sessions_state.dart index abe1385..3ee2fb9 100644 --- a/lib/features/sessions/cubit/sessions_state.dart +++ b/lib/features/sessions/cubit/sessions_state.dart @@ -3,25 +3,29 @@ part of 'sessions_cubit.dart'; class SessionsState extends Equatable { final AppState appState; final List sessions; + final List events; final String? errorMessage; const SessionsState({ this.appState = AppState.initial, this.errorMessage, this.sessions = const [], + this.events = const [], }); @override - List get props => [appState, errorMessage, sessions]; + List get props => [appState, errorMessage, sessions, events]; SessionsState copyWith({ AppState? appState, String? errorMessage, List? sessions, + List? events, }) { return SessionsState( appState: appState ?? this.appState, errorMessage: errorMessage ?? this.errorMessage, sessions: sessions ?? this.sessions, + events: events ?? this.events, ); } } diff --git a/lib/features/sessions/repo/sessions_repo.dart b/lib/features/sessions/repo/sessions_repo.dart index b33a4a6..8cabf65 100644 --- a/lib/features/sessions/repo/sessions_repo.dart +++ b/lib/features/sessions/repo/sessions_repo.dart @@ -1,3 +1,4 @@ +import 'package:pulse/features/events/models/event.dart'; import 'package:pulse/features/sessions/model/session.dart'; import 'package:pulse/utils/endpoints.dart'; import 'package:pulse/utils/requests.dart'; @@ -30,4 +31,22 @@ class SessionsRepo { return Session.fromJson(res); } + + Future> getSessionEvents({ + required String websiteId, + required String id, + DateTime? start, + DateTime? end, + }) async { + var now = DateTime.now(); + int startAt = (start ?? now.subtract(const Duration(hours: 24))) + .millisecondsSinceEpoch; + int endAt = (end ?? now).millisecondsSinceEpoch; + var res = await Requests.get( + useKey: true, + endpoint: + '${Endpoints.websites.replaceAll(Endpoints.baseUrl, 'https://api.umami.is/v1')}/$websiteId/sessions/$id/activity?startAt=$startAt&endAt=$endAt'); + logger.i(res); + return Event.toList(res); + } } diff --git a/lib/features/sessions/screens/session_details_screen.dart b/lib/features/sessions/screens/session_details_screen.dart index ac3c3fc..9b4db14 100644 --- a/lib/features/sessions/screens/session_details_screen.dart +++ b/lib/features/sessions/screens/session_details_screen.dart @@ -1,11 +1,14 @@ import 'package:country_flags/country_flags.dart'; import 'package:fade_shimmer/fade_shimmer.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_boring_avatars/flutter_boring_avatars.dart'; import 'package:icons_launcher/cli_commands.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:intl/intl.dart'; +import 'package:pulse/features/events/widgets/event_card.dart'; import 'package:pulse/features/overview/widgets/stat_card.dart'; +import 'package:pulse/features/sessions/cubit/sessions_cubit.dart'; import 'package:pulse/features/sessions/model/session.dart'; import 'package:pulse/features/sessions/repo/sessions_repo.dart'; import 'package:pulse/utils/colors.dart'; @@ -14,6 +17,9 @@ import 'package:pulse/utils/text.dart'; import 'package:pulse/utils/utils.dart'; import 'package:pulse/widgets/custom_appbar.dart'; import 'package:country_codes/country_codes.dart'; +import 'package:pulse/widgets/title_card.dart'; + +import '../../../widgets/drop_down_btn.dart'; class SessionDetailsScreen extends StatefulWidget { final Session session; @@ -25,9 +31,14 @@ class SessionDetailsScreen extends StatefulWidget { class _SessionDetailsScreenState extends State { Session? _session; + DateTimeRange? range; @override void initState() { + context.read().getSessionEvents( + websiteId: widget.session.websiteId, + id: widget.session.id, + ); Future.delayed(Duration.zero).then((_) async { try { var res = await SessionsRepo() @@ -50,137 +61,199 @@ class _SessionDetailsScreenState extends State { appBar: CustomAppBar(title: 'Session'), body: Padding( padding: EdgeInsets.all(15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 15, - children: [ - Hero( - tag: widget.session.id, - child: Align( - alignment: Alignment.center, - child: Stack( - clipBehavior: Clip.none, - children: [ - SizedBox( - width: 100, - height: 100, - child: BoringAvatar( - name: widget.session.id, - type: BoringAvatarType.beam, - shape: CircleBorder(), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 15, + children: [ + Hero( + tag: widget.session.id, + child: Align( + alignment: Alignment.center, + child: Stack( + clipBehavior: Clip.none, + children: [ + SizedBox( + width: 100, + height: 100, + child: BoringAvatar( + name: widget.session.id, + type: BoringAvatarType.beam, + shape: CircleBorder(), + ), ), - ), - Positioned( - bottom: 0, - right: 0, - child: CountryFlag.fromCountryCode( - widget.session.country, - shape: const RoundedRectangle(30), - height: 25, - width: 25, + Positioned( + bottom: 0, + right: 0, + child: CountryFlag.fromCountryCode( + widget.session.country, + shape: const RoundedRectangle(30), + height: 25, + width: 25, + ), ), - ), - ], - ), - ), - ), - Row( - spacing: 15, - children: [ - Expanded( - child: StatCard( - stat: MapEntry( - 'views', {'value': widget.session.views, 'prev': -1}), + ], ), ), - Expanded( - child: StatCard( - stat: MapEntry( - 'visits', {'value': widget.session.visits, 'prev': -1}), + ), + Row( + spacing: 15, + children: [ + Expanded( + child: StatCard( + stat: MapEntry( + 'views', {'value': widget.session.views, 'prev': -1}), + ), ), - ) - ], - ), - _session == null - ? FadeShimmer( - height: 100, - width: double.infinity, - radius: 16, - fadeTheme: FadeTheme.light, + Expanded( + child: StatCard( + stat: MapEntry('visits', + {'value': widget.session.visits, 'prev': -1}), + ), ) - : Row( - spacing: 15, - children: [ - Expanded( - child: StatCard( - stat: MapEntry('events', - {'value': _session!.events, 'prev': -1}), - ), - ), - Expanded( - child: StatCard( - stat: MapEntry('totaltime', - {'value': _session!.totaltime, 'prev': -1}), + ], + ), + _session == null + ? FadeShimmer( + height: 100, + width: double.infinity, + radius: 16, + fadeTheme: FadeTheme.light, + ) + : Row( + spacing: 15, + children: [ + Expanded( + child: StatCard( + stat: MapEntry('events', + {'value': _session!.events, 'prev': -1}), + ), ), - ) - ], - ), - getData( - title: 'First Seen', - icon: Icon( - Icons.timer_rounded, - color: kPrimaryColor.withOpacity(.8), - size: 18, + Expanded( + child: StatCard( + stat: MapEntry('totaltime', + {'value': _session!.totaltime, 'prev': -1}), + ), + ) + ], + ), + getData( + title: 'First Seen', + icon: Icon( + Icons.timer_rounded, + color: kPrimaryColor.withOpacity(.8), + size: 18, + ), + des: + '${DateFormat('EEE, MMM d y').format(widget.session.firstAt.toLocal())} at ${DateFormat('HH:mm').format(widget.session.firstAt.toLocal())} hrs', ), - des: - '${DateFormat('EEE, MMM d y').format(widget.session.firstAt.toLocal())} at ${DateFormat('HH:mm').format(widget.session.firstAt.toLocal())} hrs', - ), - getData( - title: 'Last Seen', - icon: Icon( - Icons.timer_rounded, - color: kPrimaryColor.withOpacity(.8), - size: 18, + getData( + title: 'Last Seen', + icon: Icon( + Icons.timer_rounded, + color: kPrimaryColor.withOpacity(.8), + size: 18, + ), + des: + '${DateFormat('EEE, MMM d y').format(widget.session.lastAt.toLocal())} at ${DateFormat('HH:mm').format(widget.session.lastAt.toLocal())} hrs', ), - des: - '${DateFormat('EEE, MMM d y').format(widget.session.lastAt.toLocal())} at ${DateFormat('HH:mm').format(widget.session.lastAt.toLocal())} hrs', - ), - getData( - title: 'Region', - icon: Icon( - Icons.pin_drop_rounded, - color: kPrimaryColor.withOpacity(.8), - size: 18, + getData( + title: 'Region', + icon: Icon( + Icons.pin_drop_rounded, + color: kPrimaryColor.withOpacity(.8), + size: 18, + ), + des: + '${CountryCodes.name(locale: Locale(widget.session.language, widget.session.country))}, ${widget.session.city}', ), - des: - '${CountryCodes.name(locale: Locale(widget.session.language, widget.session.country))}, ${widget.session.city}', - ), - getData( - title: 'Device', - icon: Icon( - widget.session.device.toDeviceIcon, - color: kPrimaryColor.withOpacity(.8), - size: 18, + getData( + title: 'Device', + icon: Icon( + widget.session.device.toDeviceIcon, + color: kPrimaryColor.withOpacity(.8), + size: 18, + ), + des: widget.session.device.capitalize(), ), - des: widget.session.device.capitalize(), - ), - getData( - title: 'OS', - icon: Brand( - widget.session.os.toLowerCase().toOsIcon, - size: 18, + getData( + title: 'OS', + icon: Brand( + widget.session.os.toLowerCase().toOsIcon, + size: 18, + ), + des: widget.session.os.capitalize(), ), - des: widget.session.os.capitalize(), - ), - getData( - title: 'Browser', - icon: Brand( - widget.session.browser.toLowerCase().toBrowserIcon, - size: 18, + getData( + title: 'Browser', + icon: Brand( + widget.session.browser.toLowerCase().toBrowserIcon, + size: 18, + ), + des: widget.session.browser.capitalize(), ), - des: widget.session.browser.capitalize(), - ), - ], + TitleCard(title: 'Activity'), + DropDownBtn( + click: () async { + DateTimeRange? picked = await showDateRangePicker( + initialDateRange: range ?? + DateTimeRange( + start: DateTime.now().subtract(Duration(days: 3)), + end: DateTime.now()), + context: context, + firstDate: DateTime(2000), + lastDate: DateTime.now(), + saveText: 'Done', + ); + if (picked == null) return; + setState(() => range = picked); + context.read().getSessionEvents( + websiteId: widget.session.websiteId, + id: widget.session.id, + end: range?.end, + start: range?.start, + ); + }, + icon: Iconsax.timer_1_bold, + title: range == null + ? 'Last 24 hours' + : 'From ${DateFormat('EEE, MMM dd, yyyy').format(range!.start)} - ${DateFormat('EEE, MMM dd, yyyy').format(range!.end)}', + ), + BlocConsumer( + listener: (context, state) { + if (state.appState == AppState.error) { + Toast.showToast( + message: state.errorMessage ?? 'An error occurred', + context: context, + ); + } + }, + builder: (context, state) { + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (context, index) => Divider( + color: kGreyColor.withOpacity(.2), + height: 20, + endIndent: 20, + indent: 20, + ), + itemBuilder: (_, i) => state.appState == AppState.loading + ? FadeShimmer( + height: 80, + width: double.infinity, + radius: 8, + fadeTheme: FadeTheme.light, + ) + : EventCard(event: state.events[i]), + itemCount: state.appState == AppState.loading + ? 6 + : state.events.length, + shrinkWrap: true, + ); + }, + ), + ], + ), ), ), ); diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index f477ded..f5b87d0 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -37,7 +37,6 @@ extension StringExtensions on String { } String get toBrowserIcon { - logger.i(this); if (contains('chrome')) { return Brands.chrome; } else if (contains('ios')) { From a3876a369a7c198bcbfeead7723f14f452cb406d Mon Sep 17 00:00:00 2001 From: Erick Namukolo Date: Sat, 2 Aug 2025 00:11:04 +0200 Subject: [PATCH 3/3] version++ --- lib/features/sessions/screens/session_details_screen.dart | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/sessions/screens/session_details_screen.dart b/lib/features/sessions/screens/session_details_screen.dart index 9b4db14..fb33cc5 100644 --- a/lib/features/sessions/screens/session_details_screen.dart +++ b/lib/features/sessions/screens/session_details_screen.dart @@ -192,7 +192,7 @@ class _SessionDetailsScreenState extends State { ), des: widget.session.browser.capitalize(), ), - TitleCard(title: 'Activity'), + TitleCard(title: 'Recent Activity'), DropDownBtn( click: () async { DateTimeRange? picked = await showDateRangePicker( diff --git a/pubspec.yaml b/pubspec.yaml index 991c063..a405f1a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: "A new Flutter project." # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 1.0.2+3 +version: 1.0.3+4 environment: sdk: ^3.6.1