feat: implement chat history, theme customization, and password management

- Added chat history persistence for active rooms with auto-cleanup on stream end.
- Overhauled Settings page with user profile, theme color picker, and password change.
- Added backend API for user password updates.
- Integrated flutter_launcher_icons and updated app icon to 'H' logo.
- Fixed 'Duplicate keys' bug in danmaku by using UniqueKey and filtering historical messages.
- Updated version to 1.0.0-beta3.5 and author info.
This commit is contained in:
2026-03-25 11:48:39 +08:00
parent b2a27f7801
commit a0c5e7590d
21 changed files with 446 additions and 54 deletions

View File

@@ -20,6 +20,11 @@ type LoginRequest struct {
Password string `json:"password" binding:"required"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
func Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -95,3 +100,40 @@ func Login(c *gin.Context) {
"username": user.Username,
})
}
func ChangePassword(c *gin.Context) {
userID, _ := c.Get("user_id")
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user model.User
if err := db.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Verify old password
if !utils.CheckPasswordHash(req.OldPassword, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid old password"})
return
}
// Hash new password
hashedPassword, err := utils.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// Update user
if err := db.DB.Model(&user).Update("password", hashedPassword).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
}

View File

@@ -27,6 +27,7 @@ func SetupRouter() *gin.Engine {
authGroup.Use(AuthMiddleware())
{
authGroup.GET("/room/my", GetMyRoom)
authGroup.POST("/user/change-password", ChangePassword)
}
return r

View File

@@ -16,10 +16,11 @@ const (
)
type Message struct {
Type string `json:"type"` // "chat", "system", "danmaku"
Username string `json:"username"`
Content string `json:"content"`
RoomID string `json:"room_id"`
Type string `json:"type"` // "chat", "system", "danmaku"
Username string `json:"username"`
Content string `json:"content"`
RoomID string `json:"room_id"`
IsHistory bool `json:"is_history"`
}
type Client struct {
@@ -31,19 +32,21 @@ type Client struct {
}
type Hub struct {
rooms map[string]map[*Client]bool
broadcast chan Message
register chan *Client
unregister chan *Client
mutex sync.RWMutex
rooms map[string]map[*Client]bool
roomsHistory map[string][]Message
broadcast chan Message
register chan *Client
unregister chan *Client
mutex sync.RWMutex
}
func NewHub() *Hub {
return &Hub{
broadcast: make(chan Message),
register: make(chan *Client),
unregister: make(chan *Client),
rooms: make(map[string]map[*Client]bool),
broadcast: make(chan Message),
register: make(chan *Client),
unregister: make(chan *Client),
rooms: make(map[string]map[*Client]bool),
roomsHistory: make(map[string][]Message),
}
}
@@ -56,6 +59,20 @@ func (h *Hub) Run() {
h.rooms[client.RoomID] = make(map[*Client]bool)
}
h.rooms[client.RoomID][client] = true
// Send existing history to the newly joined client
if history, ok := h.roomsHistory[client.RoomID]; ok {
for _, msg := range history {
msg.IsHistory = true
msgBytes, _ := json.Marshal(msg)
// Use select to avoid blocking if client's send channel is full
select {
case client.Send <- msgBytes:
default:
// If send fails, we could potentially log or ignore
}
}
}
h.mutex.Unlock()
case client := <-h.unregister:
@@ -64,6 +81,10 @@ func (h *Hub) Run() {
if _, ok := rooms[client]; ok {
delete(rooms, client)
close(client.Send)
// We no longer delete the room from h.rooms here if we want history to persist
// even if everyone leaves (as long as it's active in DB).
// But we should clean up if the room is empty and we want to save memory.
// However, the history is what matters.
if len(rooms) == 0 {
delete(h.rooms, client.RoomID)
}
@@ -72,7 +93,16 @@ func (h *Hub) Run() {
h.mutex.Unlock()
case message := <-h.broadcast:
h.mutex.RLock()
h.mutex.Lock()
// Only store "chat" and "danmaku" messages in history
if message.Type == "chat" || message.Type == "danmaku" {
h.roomsHistory[message.RoomID] = append(h.roomsHistory[message.RoomID], message)
// Limit history size to avoid memory leak (e.g., last 100 messages)
if len(h.roomsHistory[message.RoomID]) > 100 {
h.roomsHistory[message.RoomID] = h.roomsHistory[message.RoomID][1:]
}
}
clients := h.rooms[message.RoomID]
if clients != nil {
msgBytes, _ := json.Marshal(message)
@@ -85,11 +115,18 @@ func (h *Hub) Run() {
}
}
}
h.mutex.RUnlock()
h.mutex.Unlock()
}
}
}
// ClearRoomHistory removes history for a room, should be called when stream ends
func (h *Hub) ClearRoomHistory(roomID string) {
h.mutex.Lock()
defer h.mutex.Unlock()
delete(h.roomsHistory, roomID)
}
func (h *Hub) RegisterClient(c *Client) {
h.register <- c
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/nareix/joy4/format"
"github.com/nareix/joy4/format/rtmp"
"hightube/internal/chat"
"hightube/internal/db"
"hightube/internal/model"
)
@@ -83,6 +84,10 @@ func NewRTMPServer() *RTMPServer {
q.Close()
// Explicitly set is_active to false using map
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": false})
// Clear chat history for this room
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID))
fmt.Printf("[INFO] Publishing ended for Room ID: %d\n", room.ID)
}()

View File

@@ -2,7 +2,7 @@
<application
android:label="Hightube"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -24,21 +24,21 @@ class HightubeApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final settings = context.watch<SettingsProvider>();
return MaterialApp(
title: 'Hightube',
// 设置深色主题为主,更符合视频类应用的审美
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
seedColor: settings.themeColor,
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
seedColor: settings.themeColor,
brightness: Brightness.dark,
),
),

View File

@@ -62,7 +62,7 @@ class _PlayerPageState extends State<PlayerPage> {
if (mounted) {
setState(() {
_messages.insert(0, msg);
if (msg.type == "chat" || msg.type == "danmaku") {
if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) {
_addDanmaku(msg.content);
}
});
@@ -71,15 +71,15 @@ class _PlayerPageState extends State<PlayerPage> {
}
void _addDanmaku(String text) {
final id = DateTime.now().millisecondsSinceEpoch;
final top = 20.0 + (id % 6) * 30.0;
final key = UniqueKey();
final top = 20.0 + (DateTime.now().millisecondsSinceEpoch % 6) * 30.0;
final danmaku = _DanmakuItem(
key: ValueKey(id),
key: key,
text: text,
top: top,
onFinished: () {
if (mounted) setState(() => _danmakus.removeWhere((w) => w.key == ValueKey(id)));
if (mounted) setState(() => _danmakus.removeWhere((w) => w.key == key));
},
);

View File

@@ -1,6 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/auth_provider.dart';
import '../services/api_service.dart';
class SettingsPage extends StatefulWidget {
@override
@@ -9,6 +12,18 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
late TextEditingController _urlController;
final TextEditingController _oldPasswordController = TextEditingController();
final TextEditingController _newPasswordController = TextEditingController();
final List<Color> _availableColors = [
Colors.blue,
Colors.deepPurple,
Colors.red,
Colors.green,
Colors.orange,
Colors.teal,
Colors.pink,
];
@override
void initState() {
@@ -16,22 +31,60 @@ class _SettingsPageState extends State<SettingsPage> {
_urlController = TextEditingController(text: context.read<SettingsProvider>().baseUrl);
}
@override
void dispose() {
_urlController.dispose();
_oldPasswordController.dispose();
_newPasswordController.dispose();
super.dispose();
}
void _handleChangePassword() async {
final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>();
final api = ApiService(settings, auth.token);
if (_oldPasswordController.text.isEmpty || _newPasswordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in both password fields")));
return;
}
try {
final resp = await api.changePassword(_oldPasswordController.text, _newPasswordController.text);
final data = jsonDecode(resp.body);
if (resp.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Password updated successfully")));
_oldPasswordController.clear();
_newPasswordController.clear();
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Error: ${data['error']}")));
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to connect to server")));
}
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final settings = context.watch<SettingsProvider>();
return Scaffold(
appBar: AppBar(title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold))),
appBar: AppBar(
title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold)),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Network Configuration",
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
// User Profile Section
_buildProfileSection(auth),
SizedBox(height: 32),
// Network Configuration
_buildSectionTitle("Network Configuration"),
SizedBox(height: 16),
TextField(
controller: _urlController,
@@ -40,43 +93,172 @@ class _SettingsPageState extends State<SettingsPage> {
hintText: "http://127.0.0.1:8080",
prefixIcon: Icon(Icons.lan),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
helperText: "Restarting stream may be required after change",
),
),
SizedBox(height: 24),
SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
onPressed: () {
context.read<SettingsProvider>().setBaseUrl(_urlController.text);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Server URL Updated"),
behavior: SnackBarBehavior.floating,
),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Server URL Updated"), behavior: SnackBarBehavior.floating));
},
icon: Icon(Icons.save),
label: Text("Save Configuration"),
label: Text("Save Network Settings"),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
SizedBox(height: 32),
// Theme Color Section
_buildSectionTitle("Theme Customization"),
SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: _availableColors.map((color) {
bool isSelected = settings.themeColor.value == color.value;
return GestureDetector(
onTap: () => settings.setThemeColor(color),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null,
boxShadow: [
BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2)),
],
),
child: isSelected ? Icon(Icons.check, color: Colors.white) : null,
),
);
}).toList(),
),
SizedBox(height: 32),
// Security Section
_buildSectionTitle("Security"),
SizedBox(height: 16),
TextField(
controller: _oldPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: "Old Password",
prefixIcon: Icon(Icons.lock_outline),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
SizedBox(height: 12),
TextField(
controller: _newPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: "New Password",
prefixIcon: Icon(Icons.lock_reset),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _handleChangePassword,
icon: Icon(Icons.update),
label: Text("Change Password"),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
SizedBox(height: 40),
// About Section
Divider(),
SizedBox(height: 20),
Text(
"About Hightube",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
Center(
child: Column(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: settings.themeColor,
borderRadius: BorderRadius.circular(12),
),
child: Center(child: Text("H", style: TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold))),
),
SizedBox(height: 12),
Text("Hightube", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text("Version: 1.0.0-beta3.5", style: TextStyle(color: Colors.grey)),
Text("Author: Highground-Soft & Minimax", style: TextStyle(color: Colors.grey)),
SizedBox(height: 20),
Text("© 2026 Hightube Project", style: TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
SizedBox(height: 10),
Text("Version: 1.0.0-MVP"),
Text("Status: Phase 3.5 (UI Refinement)"),
SizedBox(height: 40),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildProfileSection(AuthProvider auth) {
return Container(
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
CircleAvatar(
radius: 35,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
(auth.username ?? "U")[0].toUpperCase(),
style: TextStyle(fontSize: 32, color: Colors.white, fontWeight: FontWeight.bold),
),
),
SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.username ?? "Unknown User",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
Text(
"Self-hosted Streamer",
style: TextStyle(color: Theme.of(context).colorScheme.outline),
),
],
),
),
IconButton(
onPressed: () => auth.logout(),
icon: Icon(Icons.logout, color: Colors.redAccent),
tooltip: "Logout",
),
],
),
);
}
}

View File

@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsProvider with ChangeNotifier {
// Default server address for local development.
// Using 10.0.2.2 for Android emulator or localhost for Desktop.
String _baseUrl = "http://localhost:8080";
Color _themeColor = Colors.blue;
String get baseUrl => _baseUrl;
Color get themeColor => _themeColor;
SettingsProvider() {
_loadSettings();
@@ -15,6 +16,10 @@ class SettingsProvider with ChangeNotifier {
void _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
_baseUrl = prefs.getString('baseUrl') ?? _baseUrl;
final colorValue = prefs.getInt('themeColor');
if (colorValue != null) {
_themeColor = Color(colorValue);
}
notifyListeners();
}
@@ -24,7 +29,14 @@ class SettingsProvider with ChangeNotifier {
await prefs.setString('baseUrl', url);
notifyListeners();
}
void setThemeColor(Color color) async {
_themeColor = color;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('themeColor', color.value);
notifyListeners();
}
// Also provide the RTMP URL based on the same hostname
String get rtmpUrl {
final uri = Uri.parse(_baseUrl);

View File

@@ -42,4 +42,12 @@ class ApiService {
headers: _headers,
);
}
Future<http.Response> changePassword(String oldPassword, String newPassword) async {
return await http.post(
Uri.parse("${settings.baseUrl}/api/user/change-password"),
headers: _headers,
body: jsonEncode({"old_password": oldPassword, "new_password": newPassword}),
);
}
}

View File

@@ -7,8 +7,15 @@ class ChatMessage {
final String username;
final String content;
final String roomId;
final bool isHistory;
ChatMessage({required this.type, required this.username, required this.content, required this.roomId});
ChatMessage({
required this.type,
required this.username,
required this.content,
required this.roomId,
this.isHistory = false,
});
factory ChatMessage.fromJson(Map<String, dynamic> json) {
return ChatMessage(
@@ -16,6 +23,7 @@ class ChatMessage {
username: json['username'] ?? 'Anonymous',
content: json['content'] ?? '',
roomId: json['room_id'] ?? '',
isHistory: json['is_history'] ?? false,
);
}
@@ -24,6 +32,7 @@ class ChatMessage {
'username': username,
'content': content,
'room_id': roomId,
'is_history': isHistory,
};
}

View File

@@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -25,6 +41,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -102,6 +134,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -168,6 +208,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
leak_tracker:
dependency: transitive
description:
@@ -312,6 +368,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -328,6 +392,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
provider:
dependency: "direct main"
description:
@@ -549,6 +621,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 1.0.0-beta3.5
environment:
sdk: ^3.11.1
@@ -44,6 +44,7 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.13.1
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
@@ -52,6 +53,21 @@ dev_dependencies:
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_launcher_icons:
android: "launcher_icon"
ios: false
image_path: "assets/icon/app_icon.png"
min_sdk_android: 21
web:
generate: true
image_path: "assets/icon/app_icon.png"
background_color: "#ffffff"
theme_color: "#2196F3"
windows:
generate: true
image_path: "assets/icon/app_icon.png"
icon_size: 256
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

View File

@@ -13,7 +13,7 @@ import 'package:hightube/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
await tester.pumpWidget(HightubeApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB